@classytic/arc 1.1.0 → 2.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +247 -794
- package/bin/arc.js +91 -52
- package/dist/EventTransport-BD2U0BTc.d.mts +100 -0
- package/dist/EventTransport-BD2U0BTc.d.mts.map +1 -0
- package/dist/HookSystem-BsGV-j2l.mjs +405 -0
- package/dist/HookSystem-BsGV-j2l.mjs.map +1 -0
- package/dist/ResourceRegistry-DsN4KJjV.mjs +250 -0
- package/dist/ResourceRegistry-DsN4KJjV.mjs.map +1 -0
- package/dist/adapters/index.d.mts +5 -0
- package/dist/adapters/index.mjs +3 -0
- package/dist/audit/index.d.mts +82 -0
- package/dist/audit/index.d.mts.map +1 -0
- package/dist/audit/index.mjs +276 -0
- package/dist/audit/index.mjs.map +1 -0
- package/dist/audit/mongodb.d.mts +5 -0
- package/dist/audit/mongodb.mjs +3 -0
- package/dist/audited-C3T5DTUx.mjs +141 -0
- package/dist/audited-C3T5DTUx.mjs.map +1 -0
- package/dist/auth/index.d.mts +189 -0
- package/dist/auth/index.d.mts.map +1 -0
- package/dist/auth/index.mjs +1102 -0
- package/dist/auth/index.mjs.map +1 -0
- package/dist/auth/redis-session.d.mts +44 -0
- package/dist/auth/redis-session.d.mts.map +1 -0
- package/dist/auth/redis-session.mjs +76 -0
- package/dist/auth/redis-session.mjs.map +1 -0
- package/dist/betterAuthOpenApi-BrHKeSAx.mjs +250 -0
- package/dist/betterAuthOpenApi-BrHKeSAx.mjs.map +1 -0
- package/dist/cache/index.d.mts +146 -0
- package/dist/cache/index.d.mts.map +1 -0
- package/dist/cache/index.mjs +92 -0
- package/dist/cache/index.mjs.map +1 -0
- package/dist/caching-Bl28lYsR.mjs +94 -0
- package/dist/caching-Bl28lYsR.mjs.map +1 -0
- package/dist/chunk-C7Uep-_p.mjs +20 -0
- package/dist/circuitBreaker-DeY4FCjs.mjs +1097 -0
- package/dist/circuitBreaker-DeY4FCjs.mjs.map +1 -0
- package/dist/cli/commands/describe.d.mts +19 -0
- package/dist/cli/commands/describe.d.mts.map +1 -0
- package/dist/cli/commands/describe.mjs +239 -0
- package/dist/cli/commands/describe.mjs.map +1 -0
- package/dist/cli/commands/docs.d.mts +14 -0
- package/dist/cli/commands/docs.d.mts.map +1 -0
- package/dist/cli/commands/docs.mjs +53 -0
- package/dist/cli/commands/docs.mjs.map +1 -0
- package/dist/cli/commands/{generate.d.ts → generate.d.mts} +3 -1
- package/dist/cli/commands/generate.d.mts.map +1 -0
- package/dist/cli/commands/generate.mjs +358 -0
- package/dist/cli/commands/generate.mjs.map +1 -0
- package/dist/cli/commands/{init.d.ts → init.d.mts} +12 -8
- package/dist/cli/commands/init.d.mts.map +1 -0
- package/dist/cli/commands/{init.js → init.mjs} +807 -616
- package/dist/cli/commands/init.mjs.map +1 -0
- package/dist/cli/commands/introspect.d.mts +11 -0
- package/dist/cli/commands/introspect.d.mts.map +1 -0
- package/dist/cli/commands/introspect.mjs +76 -0
- package/dist/cli/commands/introspect.mjs.map +1 -0
- package/dist/cli/index.d.mts +17 -0
- package/dist/cli/index.d.mts.map +1 -0
- package/dist/cli/index.mjs +157 -0
- package/dist/cli/index.mjs.map +1 -0
- package/dist/constants-DdXFXQtN.mjs +85 -0
- package/dist/constants-DdXFXQtN.mjs.map +1 -0
- package/dist/core/index.d.mts +5 -0
- package/dist/core/index.mjs +4 -0
- package/dist/createApp-CUgNqegw.mjs +560 -0
- package/dist/createApp-CUgNqegw.mjs.map +1 -0
- package/dist/defineResource-k0_BDn8v.mjs +2197 -0
- package/dist/defineResource-k0_BDn8v.mjs.map +1 -0
- package/dist/discovery/index.d.mts +47 -0
- package/dist/discovery/index.d.mts.map +1 -0
- package/dist/discovery/index.mjs +110 -0
- package/dist/discovery/index.mjs.map +1 -0
- package/dist/docs/index.d.mts +163 -0
- package/dist/docs/index.d.mts.map +1 -0
- package/dist/docs/index.mjs +73 -0
- package/dist/docs/index.mjs.map +1 -0
- package/dist/elevation-BRy3yFWT.mjs +113 -0
- package/dist/elevation-BRy3yFWT.mjs.map +1 -0
- package/dist/elevation-B_2dRLVP.d.mts +88 -0
- package/dist/elevation-B_2dRLVP.d.mts.map +1 -0
- package/dist/errorHandler-BbcgBmIH.d.mts +73 -0
- package/dist/errorHandler-BbcgBmIH.d.mts.map +1 -0
- package/dist/errorHandler-C1okiriz.mjs +109 -0
- package/dist/errorHandler-C1okiriz.mjs.map +1 -0
- package/dist/errors-B9bZok84.mjs +212 -0
- package/dist/errors-B9bZok84.mjs.map +1 -0
- package/dist/errors-ChKiFz62.d.mts +125 -0
- package/dist/errors-ChKiFz62.d.mts.map +1 -0
- package/dist/eventPlugin-CTrLH3mt.d.mts +125 -0
- package/dist/eventPlugin-CTrLH3mt.d.mts.map +1 -0
- package/dist/eventPlugin-DGR_B2on.mjs +230 -0
- package/dist/eventPlugin-DGR_B2on.mjs.map +1 -0
- package/dist/events/index.d.mts +54 -0
- package/dist/events/index.d.mts.map +1 -0
- package/dist/events/index.mjs +52 -0
- package/dist/events/index.mjs.map +1 -0
- package/dist/events/transports/redis-stream-entry.d.mts +2 -0
- package/dist/events/transports/redis-stream-entry.mjs +178 -0
- package/dist/events/transports/redis-stream-entry.mjs.map +1 -0
- package/dist/events/transports/redis.d.mts +77 -0
- package/dist/events/transports/redis.d.mts.map +1 -0
- package/dist/events/transports/redis.mjs +125 -0
- package/dist/events/transports/redis.mjs.map +1 -0
- package/dist/externalPaths-DlINfKbP.d.mts +51 -0
- package/dist/externalPaths-DlINfKbP.d.mts.map +1 -0
- package/dist/factory/index.d.mts +64 -0
- package/dist/factory/index.d.mts.map +1 -0
- package/dist/factory/index.mjs +3 -0
- package/dist/fastifyAdapter-BkrGrlFi.d.mts +217 -0
- package/dist/fastifyAdapter-BkrGrlFi.d.mts.map +1 -0
- package/dist/fields-DyaDVX4J.d.mts +110 -0
- package/dist/fields-DyaDVX4J.d.mts.map +1 -0
- package/dist/fields-iagOozy0.mjs +115 -0
- package/dist/fields-iagOozy0.mjs.map +1 -0
- package/dist/hooks/index.d.mts +4 -0
- package/dist/hooks/index.mjs +3 -0
- package/dist/idempotency/index.d.mts +97 -0
- package/dist/idempotency/index.d.mts.map +1 -0
- package/dist/idempotency/index.mjs +320 -0
- package/dist/idempotency/index.mjs.map +1 -0
- package/dist/idempotency/mongodb.d.mts +2 -0
- package/dist/idempotency/mongodb.mjs +115 -0
- package/dist/idempotency/mongodb.mjs.map +1 -0
- package/dist/idempotency/redis.d.mts +2 -0
- package/dist/idempotency/redis.mjs +104 -0
- package/dist/idempotency/redis.mjs.map +1 -0
- package/dist/index.d.mts +261 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +105 -0
- package/dist/index.mjs.map +1 -0
- package/dist/integrations/event-gateway.d.mts +47 -0
- package/dist/integrations/event-gateway.d.mts.map +1 -0
- package/dist/integrations/event-gateway.mjs +44 -0
- package/dist/integrations/event-gateway.mjs.map +1 -0
- package/dist/integrations/index.d.mts +5 -0
- package/dist/integrations/index.mjs +1 -0
- package/dist/integrations/jobs.d.mts +104 -0
- package/dist/integrations/jobs.d.mts.map +1 -0
- package/dist/integrations/jobs.mjs +124 -0
- package/dist/integrations/jobs.mjs.map +1 -0
- package/dist/integrations/streamline.d.mts +61 -0
- package/dist/integrations/streamline.d.mts.map +1 -0
- package/dist/integrations/streamline.mjs +126 -0
- package/dist/integrations/streamline.mjs.map +1 -0
- package/dist/integrations/websocket.d.mts +83 -0
- package/dist/integrations/websocket.d.mts.map +1 -0
- package/dist/integrations/websocket.mjs +289 -0
- package/dist/integrations/websocket.mjs.map +1 -0
- package/dist/interface-B01JvPVc.d.mts +78 -0
- package/dist/interface-B01JvPVc.d.mts.map +1 -0
- package/dist/interface-CZe8IkMf.d.mts +55 -0
- package/dist/interface-CZe8IkMf.d.mts.map +1 -0
- package/dist/interface-Ch8HU9uM.d.mts +1098 -0
- package/dist/interface-Ch8HU9uM.d.mts.map +1 -0
- package/dist/introspectionPlugin-rFdO8ZUa.mjs +54 -0
- package/dist/introspectionPlugin-rFdO8ZUa.mjs.map +1 -0
- package/dist/keys-BqNejWup.mjs +43 -0
- package/dist/keys-BqNejWup.mjs.map +1 -0
- package/dist/logger-Df2O2WsW.mjs +79 -0
- package/dist/logger-Df2O2WsW.mjs.map +1 -0
- package/dist/memory-cQgelFOj.mjs +144 -0
- package/dist/memory-cQgelFOj.mjs.map +1 -0
- package/dist/migrations/index.d.mts +157 -0
- package/dist/migrations/index.d.mts.map +1 -0
- package/dist/migrations/index.mjs +261 -0
- package/dist/migrations/index.mjs.map +1 -0
- package/dist/mongodb-BfJVlUJH.mjs +94 -0
- package/dist/mongodb-BfJVlUJH.mjs.map +1 -0
- package/dist/mongodb-CGzRbfAK.d.mts +119 -0
- package/dist/mongodb-CGzRbfAK.d.mts.map +1 -0
- package/dist/mongodb-JN-9JA7K.d.mts +72 -0
- package/dist/mongodb-JN-9JA7K.d.mts.map +1 -0
- package/dist/openapi-G3Cw7XuM.mjs +524 -0
- package/dist/openapi-G3Cw7XuM.mjs.map +1 -0
- package/dist/org/index.d.mts +69 -0
- package/dist/org/index.d.mts.map +1 -0
- package/dist/org/index.mjs +514 -0
- package/dist/org/index.mjs.map +1 -0
- package/dist/org/types.d.mts +83 -0
- package/dist/org/types.d.mts.map +1 -0
- package/dist/org/types.mjs +1 -0
- package/dist/permissions/index.d.mts +279 -0
- package/dist/permissions/index.d.mts.map +1 -0
- package/dist/permissions/index.mjs +579 -0
- package/dist/permissions/index.mjs.map +1 -0
- package/dist/plugins/index.d.mts +173 -0
- package/dist/plugins/index.d.mts.map +1 -0
- package/dist/plugins/index.mjs +523 -0
- package/dist/plugins/index.mjs.map +1 -0
- package/dist/plugins/response-cache.d.mts +88 -0
- package/dist/plugins/response-cache.d.mts.map +1 -0
- package/dist/plugins/response-cache.mjs +284 -0
- package/dist/plugins/response-cache.mjs.map +1 -0
- package/dist/plugins/tracing-entry.d.mts +2 -0
- package/dist/plugins/tracing-entry.mjs +186 -0
- package/dist/plugins/tracing-entry.mjs.map +1 -0
- package/dist/pluralize-CEweyOEm.mjs +87 -0
- package/dist/pluralize-CEweyOEm.mjs.map +1 -0
- package/dist/policies/{index.d.ts → index.d.mts} +204 -169
- package/dist/policies/index.d.mts.map +1 -0
- package/dist/policies/index.mjs +322 -0
- package/dist/policies/index.mjs.map +1 -0
- package/dist/presets/{index.d.ts → index.d.mts} +63 -131
- package/dist/presets/index.d.mts.map +1 -0
- package/dist/presets/index.mjs +144 -0
- package/dist/presets/index.mjs.map +1 -0
- package/dist/presets/multiTenant.d.mts +25 -0
- package/dist/presets/multiTenant.d.mts.map +1 -0
- package/dist/presets/multiTenant.mjs +114 -0
- package/dist/presets/multiTenant.mjs.map +1 -0
- package/dist/presets-BITljm96.mjs +120 -0
- package/dist/presets-BITljm96.mjs.map +1 -0
- package/dist/presets-DzSMwlKj.d.mts +58 -0
- package/dist/presets-DzSMwlKj.d.mts.map +1 -0
- package/dist/prisma-DJbMt3yf.mjs +628 -0
- package/dist/prisma-DJbMt3yf.mjs.map +1 -0
- package/dist/prisma-Dg9GoVdj.d.mts +275 -0
- package/dist/prisma-Dg9GoVdj.d.mts.map +1 -0
- package/dist/queryCachePlugin-7THaI5mt.d.mts +72 -0
- package/dist/queryCachePlugin-7THaI5mt.d.mts.map +1 -0
- package/dist/queryCachePlugin-DMBnp2Q0.mjs +139 -0
- package/dist/queryCachePlugin-DMBnp2Q0.mjs.map +1 -0
- package/dist/redis-D-JAeLtm.d.mts +50 -0
- package/dist/redis-D-JAeLtm.d.mts.map +1 -0
- package/dist/redis-stream-Bdh_vUU8.d.mts +104 -0
- package/dist/redis-stream-Bdh_vUU8.d.mts.map +1 -0
- package/dist/registry/index.d.mts +12 -0
- package/dist/registry/index.d.mts.map +1 -0
- package/dist/registry/index.mjs +4 -0
- package/dist/requestContext-QQD6ROJc.mjs +56 -0
- package/dist/requestContext-QQD6ROJc.mjs.map +1 -0
- package/dist/schemaConverter-BwrmWroW.mjs +99 -0
- package/dist/schemaConverter-BwrmWroW.mjs.map +1 -0
- package/dist/schemas/index.d.mts +64 -0
- package/dist/schemas/index.d.mts.map +1 -0
- package/dist/schemas/index.mjs +83 -0
- package/dist/schemas/index.mjs.map +1 -0
- package/dist/scope/index.d.mts +22 -0
- package/dist/scope/index.d.mts.map +1 -0
- package/dist/scope/index.mjs +66 -0
- package/dist/scope/index.mjs.map +1 -0
- package/dist/sessionManager-jPKLbHE0.d.mts +187 -0
- package/dist/sessionManager-jPKLbHE0.d.mts.map +1 -0
- package/dist/sse-B3c3_yZp.mjs +124 -0
- package/dist/sse-B3c3_yZp.mjs.map +1 -0
- package/dist/testing/index.d.mts +908 -0
- package/dist/testing/index.d.mts.map +1 -0
- package/dist/testing/index.mjs +1977 -0
- package/dist/testing/index.mjs.map +1 -0
- package/dist/tracing-Cc7vVQPp.d.mts +71 -0
- package/dist/tracing-Cc7vVQPp.d.mts.map +1 -0
- package/dist/typeGuards-DhMNLuvU.mjs +10 -0
- package/dist/typeGuards-DhMNLuvU.mjs.map +1 -0
- package/dist/types/index.d.mts +947 -0
- package/dist/types/index.d.mts.map +1 -0
- package/dist/types/index.mjs +15 -0
- package/dist/types/index.mjs.map +1 -0
- package/dist/types-Beqn1Un7.mjs +39 -0
- package/dist/types-Beqn1Un7.mjs.map +1 -0
- package/dist/types-CIgB7UUl.d.mts +446 -0
- package/dist/types-CIgB7UUl.d.mts.map +1 -0
- package/dist/types-aYB4V7uN.d.mts +87 -0
- package/dist/types-aYB4V7uN.d.mts.map +1 -0
- package/dist/utils/index.d.mts +748 -0
- package/dist/utils/index.d.mts.map +1 -0
- package/dist/utils/index.mjs +6 -0
- package/package.json +194 -68
- package/dist/BaseController-DVAiHxEQ.d.ts +0 -233
- package/dist/adapters/index.d.ts +0 -237
- package/dist/adapters/index.js +0 -668
- package/dist/arcCorePlugin-CsShQdyP.d.ts +0 -273
- package/dist/audit/index.d.ts +0 -195
- package/dist/audit/index.js +0 -319
- package/dist/auth/index.d.ts +0 -47
- package/dist/auth/index.js +0 -174
- package/dist/cli/commands/docs.d.ts +0 -11
- package/dist/cli/commands/docs.js +0 -474
- package/dist/cli/commands/generate.js +0 -334
- package/dist/cli/commands/introspect.d.ts +0 -8
- package/dist/cli/commands/introspect.js +0 -338
- package/dist/cli/index.d.ts +0 -4
- package/dist/cli/index.js +0 -3269
- package/dist/core/index.d.ts +0 -220
- package/dist/core/index.js +0 -2786
- package/dist/createApp-Ce9wl8W9.d.ts +0 -77
- package/dist/docs/index.d.ts +0 -166
- package/dist/docs/index.js +0 -658
- package/dist/errors-8WIxGS_6.d.ts +0 -122
- package/dist/events/index.d.ts +0 -117
- package/dist/events/index.js +0 -89
- package/dist/factory/index.d.ts +0 -38
- package/dist/factory/index.js +0 -1652
- package/dist/hooks/index.d.ts +0 -4
- package/dist/hooks/index.js +0 -199
- package/dist/idempotency/index.d.ts +0 -323
- package/dist/idempotency/index.js +0 -500
- package/dist/index-B4t03KQ0.d.ts +0 -1366
- package/dist/index.d.ts +0 -135
- package/dist/index.js +0 -4756
- package/dist/migrations/index.d.ts +0 -185
- package/dist/migrations/index.js +0 -274
- package/dist/org/index.d.ts +0 -129
- package/dist/org/index.js +0 -220
- package/dist/permissions/index.d.ts +0 -144
- package/dist/permissions/index.js +0 -103
- package/dist/plugins/index.d.ts +0 -46
- package/dist/plugins/index.js +0 -1069
- package/dist/policies/index.js +0 -196
- package/dist/presets/index.js +0 -384
- package/dist/presets/multiTenant.d.ts +0 -39
- package/dist/presets/multiTenant.js +0 -112
- package/dist/registry/index.d.ts +0 -16
- package/dist/registry/index.js +0 -253
- package/dist/testing/index.d.ts +0 -618
- package/dist/testing/index.js +0 -48020
- package/dist/types/index.d.ts +0 -4
- package/dist/types/index.js +0 -8
- package/dist/types-B99TBmFV.d.ts +0 -76
- package/dist/types-BvckRbs2.d.ts +0 -143
- package/dist/utils/index.d.ts +0 -679
- package/dist/utils/index.js +0 -931
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
//#region src/permissions/fields.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Field-Level Permissions
|
|
4
|
+
*
|
|
5
|
+
* Control field visibility and writability per role.
|
|
6
|
+
* Integrated into the response path (read) and sanitization path (write).
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```typescript
|
|
10
|
+
* import { fields, defineResource } from '@classytic/arc';
|
|
11
|
+
*
|
|
12
|
+
* const userResource = defineResource({
|
|
13
|
+
* name: 'user',
|
|
14
|
+
* adapter: userAdapter,
|
|
15
|
+
* fields: {
|
|
16
|
+
* salary: fields.visibleTo(['admin', 'hr']),
|
|
17
|
+
* internalNotes: fields.writableBy(['admin']),
|
|
18
|
+
* email: fields.redactFor(['viewer']),
|
|
19
|
+
* password: fields.hidden(),
|
|
20
|
+
* },
|
|
21
|
+
* });
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
type FieldPermissionType = 'hidden' | 'visibleTo' | 'writableBy' | 'redactFor';
|
|
25
|
+
interface FieldPermission {
|
|
26
|
+
readonly _type: FieldPermissionType;
|
|
27
|
+
readonly roles?: readonly string[];
|
|
28
|
+
readonly redactValue?: unknown;
|
|
29
|
+
}
|
|
30
|
+
type FieldPermissionMap = Record<string, FieldPermission>;
|
|
31
|
+
declare const fields: {
|
|
32
|
+
/**
|
|
33
|
+
* Field is never included in responses. Not writable via API.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```typescript
|
|
37
|
+
* fields: { password: fields.hidden() }
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
hidden(): FieldPermission;
|
|
41
|
+
/**
|
|
42
|
+
* Field is only visible to users with specified roles.
|
|
43
|
+
* Other users don't see the field at all.
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* ```typescript
|
|
47
|
+
* fields: { salary: fields.visibleTo(['admin', 'hr']) }
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
visibleTo(roles: readonly string[]): FieldPermission;
|
|
51
|
+
/**
|
|
52
|
+
* Field is only writable by users with specified roles.
|
|
53
|
+
* All users can still read the field. Users without the role
|
|
54
|
+
* have the field silently stripped from write operations.
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* ```typescript
|
|
58
|
+
* fields: { role: fields.writableBy(['admin']) }
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
writableBy(roles: readonly string[]): FieldPermission;
|
|
62
|
+
/**
|
|
63
|
+
* Field is redacted (replaced with a placeholder) for specified roles.
|
|
64
|
+
* Other users see the real value.
|
|
65
|
+
*
|
|
66
|
+
* @param roles - Roles that see the redacted value
|
|
67
|
+
* @param redactValue - Replacement value (default: '***')
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* ```typescript
|
|
71
|
+
* fields: {
|
|
72
|
+
* email: fields.redactFor(['viewer']),
|
|
73
|
+
* ssn: fields.redactFor(['basic'], '***-**-****'),
|
|
74
|
+
* }
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
redactFor(roles: readonly string[], redactValue?: unknown): FieldPermission;
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* Apply field-level READ permissions to a response object.
|
|
81
|
+
* Strips hidden fields, enforces visibility, and applies redaction.
|
|
82
|
+
*
|
|
83
|
+
* @param data - The response object (mutated in place for performance)
|
|
84
|
+
* @param fieldPermissions - Field permission map from resource config
|
|
85
|
+
* @param userRoles - Current user's roles (empty array for unauthenticated)
|
|
86
|
+
* @returns The filtered object
|
|
87
|
+
*/
|
|
88
|
+
declare function applyFieldReadPermissions<T extends Record<string, unknown>>(data: T, fieldPermissions: FieldPermissionMap, userRoles: readonly string[]): T;
|
|
89
|
+
/**
|
|
90
|
+
* Apply field-level WRITE permissions to request body.
|
|
91
|
+
* Strips fields that the user doesn't have permission to write.
|
|
92
|
+
*
|
|
93
|
+
* @param body - The request body (returns a new filtered copy)
|
|
94
|
+
* @param fieldPermissions - Field permission map from resource config
|
|
95
|
+
* @param userRoles - Current user's roles
|
|
96
|
+
* @returns Filtered body
|
|
97
|
+
*/
|
|
98
|
+
declare function applyFieldWritePermissions<T extends Record<string, unknown>>(body: T, fieldPermissions: FieldPermissionMap, userRoles: readonly string[]): T;
|
|
99
|
+
/**
|
|
100
|
+
* Resolve effective roles by merging global user roles with org-level roles.
|
|
101
|
+
*
|
|
102
|
+
* Global roles come from `req.user.roles` (Better Auth user object).
|
|
103
|
+
* Org roles come from `req.context.orgRoles` (set by BA adapter's org bridge).
|
|
104
|
+
*
|
|
105
|
+
* When no org context exists, returns global roles only — backward compatible.
|
|
106
|
+
*/
|
|
107
|
+
declare function resolveEffectiveRoles(userRoles: readonly string[], orgRoles: readonly string[]): string[];
|
|
108
|
+
//#endregion
|
|
109
|
+
export { applyFieldWritePermissions as a, applyFieldReadPermissions as i, FieldPermissionMap as n, fields as o, FieldPermissionType as r, resolveEffectiveRoles as s, FieldPermission as t };
|
|
110
|
+
//# sourceMappingURL=fields-DyaDVX4J.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fields-DyaDVX4J.d.mts","names":[],"sources":["../src/permissions/fields.ts"],"mappings":";;AAgCA;;;;;AAEA;;;;;;;;;;AAMA;;;;;AAMA;KAdY,mBAAA;AAAA,UAEK,eAAA;EAAA,SACN,KAAA,EAAO,mBAAA;EAAA,SACP,KAAA;EAAA,SACA,WAAA;AAAA;AAAA,KAGC,kBAAA,GAAqB,MAAA,SAAe,eAAA;AAAA,cAMnC,MAAA;;;;;;;;;YASD,eAAA;;;;;;AAgEZ;;;;uCAnDuC,eAAA;EAqDnB;;;;;;;;;;wCAvCoB,eAAA;EAyCrC;;;AAgDH;;;;;;;;;;;;sCAtEoC,WAAA,aAAiC,eAAA;AAAA;;;;;;AA8GrE;;;;iBA5FgB,yBAAA,WAAoC,MAAA,kBAAA,CAClD,IAAA,EAAM,CAAA,EACN,gBAAA,EAAkB,kBAAA,EAClB,SAAA,sBACC,CAAA;;;;;;;;;;iBAgDa,0BAAA,WAAqC,MAAA,kBAAA,CACnD,IAAA,EAAM,CAAA,EACN,gBAAA,EAAkB,kBAAA,EAClB,SAAA,sBACC,CAAA;;;;;;;;;iBAoCa,qBAAA,CACd,SAAA,qBACA,QAAA"}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
//#region src/permissions/fields.ts
|
|
2
|
+
/**
|
|
3
|
+
* Field-Level Permissions
|
|
4
|
+
*
|
|
5
|
+
* Control field visibility and writability per role.
|
|
6
|
+
* Integrated into the response path (read) and sanitization path (write).
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```typescript
|
|
10
|
+
* import { fields, defineResource } from '@classytic/arc';
|
|
11
|
+
*
|
|
12
|
+
* const userResource = defineResource({
|
|
13
|
+
* name: 'user',
|
|
14
|
+
* adapter: userAdapter,
|
|
15
|
+
* fields: {
|
|
16
|
+
* salary: fields.visibleTo(['admin', 'hr']),
|
|
17
|
+
* internalNotes: fields.writableBy(['admin']),
|
|
18
|
+
* email: fields.redactFor(['viewer']),
|
|
19
|
+
* password: fields.hidden(),
|
|
20
|
+
* },
|
|
21
|
+
* });
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
/** Type guard for Mongoose-like documents with toObject() */
|
|
25
|
+
function isMongooseDoc(obj) {
|
|
26
|
+
return !!obj && typeof obj === "object" && "toObject" in obj && typeof obj.toObject === "function";
|
|
27
|
+
}
|
|
28
|
+
const fields = {
|
|
29
|
+
hidden() {
|
|
30
|
+
return { _type: "hidden" };
|
|
31
|
+
},
|
|
32
|
+
visibleTo(roles) {
|
|
33
|
+
return {
|
|
34
|
+
_type: "visibleTo",
|
|
35
|
+
roles
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
writableBy(roles) {
|
|
39
|
+
return {
|
|
40
|
+
_type: "writableBy",
|
|
41
|
+
roles
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
redactFor(roles, redactValue = "***") {
|
|
45
|
+
return {
|
|
46
|
+
_type: "redactFor",
|
|
47
|
+
roles,
|
|
48
|
+
redactValue
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Apply field-level READ permissions to a response object.
|
|
54
|
+
* Strips hidden fields, enforces visibility, and applies redaction.
|
|
55
|
+
*
|
|
56
|
+
* @param data - The response object (mutated in place for performance)
|
|
57
|
+
* @param fieldPermissions - Field permission map from resource config
|
|
58
|
+
* @param userRoles - Current user's roles (empty array for unauthenticated)
|
|
59
|
+
* @returns The filtered object
|
|
60
|
+
*/
|
|
61
|
+
function applyFieldReadPermissions(data, fieldPermissions, userRoles) {
|
|
62
|
+
if (!data || typeof data !== "object") return data;
|
|
63
|
+
const result = { ...isMongooseDoc(data) ? data.toObject() : data };
|
|
64
|
+
for (const [field, perm] of Object.entries(fieldPermissions)) switch (perm._type) {
|
|
65
|
+
case "hidden":
|
|
66
|
+
delete result[field];
|
|
67
|
+
break;
|
|
68
|
+
case "visibleTo":
|
|
69
|
+
if (!perm.roles?.some((r) => userRoles.includes(r))) delete result[field];
|
|
70
|
+
break;
|
|
71
|
+
case "redactFor":
|
|
72
|
+
if (perm.roles?.some((r) => userRoles.includes(r))) result[field] = perm.redactValue ?? "***";
|
|
73
|
+
break;
|
|
74
|
+
case "writableBy": break;
|
|
75
|
+
}
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Apply field-level WRITE permissions to request body.
|
|
80
|
+
* Strips fields that the user doesn't have permission to write.
|
|
81
|
+
*
|
|
82
|
+
* @param body - The request body (returns a new filtered copy)
|
|
83
|
+
* @param fieldPermissions - Field permission map from resource config
|
|
84
|
+
* @param userRoles - Current user's roles
|
|
85
|
+
* @returns Filtered body
|
|
86
|
+
*/
|
|
87
|
+
function applyFieldWritePermissions(body, fieldPermissions, userRoles) {
|
|
88
|
+
const result = { ...body };
|
|
89
|
+
for (const [field, perm] of Object.entries(fieldPermissions)) switch (perm._type) {
|
|
90
|
+
case "hidden":
|
|
91
|
+
delete result[field];
|
|
92
|
+
break;
|
|
93
|
+
case "writableBy":
|
|
94
|
+
if (field in result && !perm.roles?.some((r) => userRoles.includes(r))) delete result[field];
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Resolve effective roles by merging global user roles with org-level roles.
|
|
101
|
+
*
|
|
102
|
+
* Global roles come from `req.user.roles` (Better Auth user object).
|
|
103
|
+
* Org roles come from `req.context.orgRoles` (set by BA adapter's org bridge).
|
|
104
|
+
*
|
|
105
|
+
* When no org context exists, returns global roles only — backward compatible.
|
|
106
|
+
*/
|
|
107
|
+
function resolveEffectiveRoles(userRoles, orgRoles) {
|
|
108
|
+
if (orgRoles.length === 0) return [...userRoles];
|
|
109
|
+
if (userRoles.length === 0) return [...orgRoles];
|
|
110
|
+
return [...new Set([...userRoles, ...orgRoles])];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
//#endregion
|
|
114
|
+
export { resolveEffectiveRoles as i, applyFieldWritePermissions as n, fields as r, applyFieldReadPermissions as t };
|
|
115
|
+
//# sourceMappingURL=fields-iagOozy0.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fields-iagOozy0.mjs","names":[],"sources":["../src/permissions/fields.ts"],"sourcesContent":["/**\n * Field-Level Permissions\n *\n * Control field visibility and writability per role.\n * Integrated into the response path (read) and sanitization path (write).\n *\n * @example\n * ```typescript\n * import { fields, defineResource } from '@classytic/arc';\n *\n * const userResource = defineResource({\n * name: 'user',\n * adapter: userAdapter,\n * fields: {\n * salary: fields.visibleTo(['admin', 'hr']),\n * internalNotes: fields.writableBy(['admin']),\n * email: fields.redactFor(['viewer']),\n * password: fields.hidden(),\n * },\n * });\n * ```\n */\n\n/** Type guard for Mongoose-like documents with toObject() */\nfunction isMongooseDoc(obj: unknown): obj is { toObject(): Record<string, unknown> } {\n return !!obj && typeof obj === 'object' && 'toObject' in obj && typeof (obj as Record<string, unknown>).toObject === 'function';\n}\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport type FieldPermissionType = 'hidden' | 'visibleTo' | 'writableBy' | 'redactFor';\n\nexport interface FieldPermission {\n readonly _type: FieldPermissionType;\n readonly roles?: readonly string[];\n readonly redactValue?: unknown;\n}\n\nexport type FieldPermissionMap = Record<string, FieldPermission>;\n\n// ---------------------------------------------------------------------------\n// Field Permission Helpers\n// ---------------------------------------------------------------------------\n\nexport const fields = {\n /**\n * Field is never included in responses. Not writable via API.\n *\n * @example\n * ```typescript\n * fields: { password: fields.hidden() }\n * ```\n */\n hidden(): FieldPermission {\n return { _type: 'hidden' };\n },\n\n /**\n * Field is only visible to users with specified roles.\n * Other users don't see the field at all.\n *\n * @example\n * ```typescript\n * fields: { salary: fields.visibleTo(['admin', 'hr']) }\n * ```\n */\n visibleTo(roles: readonly string[]): FieldPermission {\n return { _type: 'visibleTo', roles };\n },\n\n /**\n * Field is only writable by users with specified roles.\n * All users can still read the field. Users without the role\n * have the field silently stripped from write operations.\n *\n * @example\n * ```typescript\n * fields: { role: fields.writableBy(['admin']) }\n * ```\n */\n writableBy(roles: readonly string[]): FieldPermission {\n return { _type: 'writableBy', roles };\n },\n\n /**\n * Field is redacted (replaced with a placeholder) for specified roles.\n * Other users see the real value.\n *\n * @param roles - Roles that see the redacted value\n * @param redactValue - Replacement value (default: '***')\n *\n * @example\n * ```typescript\n * fields: {\n * email: fields.redactFor(['viewer']),\n * ssn: fields.redactFor(['basic'], '***-**-****'),\n * }\n * ```\n */\n redactFor(roles: readonly string[], redactValue: unknown = '***'): FieldPermission {\n return { _type: 'redactFor', roles, redactValue };\n },\n};\n\n// ---------------------------------------------------------------------------\n// Application Functions\n// ---------------------------------------------------------------------------\n\n/**\n * Apply field-level READ permissions to a response object.\n * Strips hidden fields, enforces visibility, and applies redaction.\n *\n * @param data - The response object (mutated in place for performance)\n * @param fieldPermissions - Field permission map from resource config\n * @param userRoles - Current user's roles (empty array for unauthenticated)\n * @returns The filtered object\n */\nexport function applyFieldReadPermissions<T extends Record<string, unknown>>(\n data: T,\n fieldPermissions: FieldPermissionMap,\n userRoles: readonly string[],\n): T {\n if (!data || typeof data !== 'object') return data;\n\n // Normalize Mongoose documents to plain objects before spreading.\n // HydratedDocument's spread gives internal properties ($__, $isNew, etc.),\n // not the actual document fields — toObject() returns a proper plain object.\n const plain = isMongooseDoc(data) ? data.toObject() as T : data;\n const result = { ...plain };\n\n for (const [field, perm] of Object.entries(fieldPermissions)) {\n switch (perm._type) {\n case 'hidden':\n // Always strip\n delete result[field];\n break;\n\n case 'visibleTo':\n // Strip if user doesn't have any of the required roles\n if (!perm.roles?.some((r) => userRoles.includes(r))) {\n delete result[field];\n }\n break;\n\n case 'redactFor':\n // Redact if user HAS any of the specified roles\n if (perm.roles?.some((r) => userRoles.includes(r))) {\n (result as Record<string, unknown>)[field] = perm.redactValue ?? '***';\n }\n break;\n\n case 'writableBy':\n // Write-only permission — no effect on reads\n break;\n }\n }\n\n return result;\n}\n\n/**\n * Apply field-level WRITE permissions to request body.\n * Strips fields that the user doesn't have permission to write.\n *\n * @param body - The request body (returns a new filtered copy)\n * @param fieldPermissions - Field permission map from resource config\n * @param userRoles - Current user's roles\n * @returns Filtered body\n */\nexport function applyFieldWritePermissions<T extends Record<string, unknown>>(\n body: T,\n fieldPermissions: FieldPermissionMap,\n userRoles: readonly string[],\n): T {\n const result = { ...body };\n\n for (const [field, perm] of Object.entries(fieldPermissions)) {\n switch (perm._type) {\n case 'hidden':\n // Hidden fields can never be written\n delete result[field];\n break;\n\n case 'writableBy':\n // Only writable by specific roles — strip if user lacks them\n if (field in result && !perm.roles?.some((r) => userRoles.includes(r))) {\n delete result[field];\n }\n break;\n\n // visibleTo and redactFor don't affect writes\n }\n }\n\n return result;\n}\n\n// ---------------------------------------------------------------------------\n// Role Resolution\n// ---------------------------------------------------------------------------\n\n/**\n * Resolve effective roles by merging global user roles with org-level roles.\n *\n * Global roles come from `req.user.roles` (Better Auth user object).\n * Org roles come from `req.context.orgRoles` (set by BA adapter's org bridge).\n *\n * When no org context exists, returns global roles only — backward compatible.\n */\nexport function resolveEffectiveRoles(\n userRoles: readonly string[],\n orgRoles: readonly string[],\n): string[] {\n if (orgRoles.length === 0) return [...userRoles];\n if (userRoles.length === 0) return [...orgRoles];\n return [...new Set([...userRoles, ...orgRoles])];\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;AAwBA,SAAS,cAAc,KAA8D;AACnF,QAAO,CAAC,CAAC,OAAO,OAAO,QAAQ,YAAY,cAAc,OAAO,OAAQ,IAAgC,aAAa;;AAqBvH,MAAa,SAAS;CASpB,SAA0B;AACxB,SAAO,EAAE,OAAO,UAAU;;CAY5B,UAAU,OAA2C;AACnD,SAAO;GAAE,OAAO;GAAa;GAAO;;CAatC,WAAW,OAA2C;AACpD,SAAO;GAAE,OAAO;GAAc;GAAO;;CAkBvC,UAAU,OAA0B,cAAuB,OAAwB;AACjF,SAAO;GAAE,OAAO;GAAa;GAAO;GAAa;;CAEpD;;;;;;;;;;AAeD,SAAgB,0BACd,MACA,kBACA,WACG;AACH,KAAI,CAAC,QAAQ,OAAO,SAAS,SAAU,QAAO;CAM9C,MAAM,SAAS,EAAE,GADH,cAAc,KAAK,GAAG,KAAK,UAAU,GAAQ,MAChC;AAE3B,MAAK,MAAM,CAAC,OAAO,SAAS,OAAO,QAAQ,iBAAiB,CAC1D,SAAQ,KAAK,OAAb;EACE,KAAK;AAEH,UAAO,OAAO;AACd;EAEF,KAAK;AAEH,OAAI,CAAC,KAAK,OAAO,MAAM,MAAM,UAAU,SAAS,EAAE,CAAC,CACjD,QAAO,OAAO;AAEhB;EAEF,KAAK;AAEH,OAAI,KAAK,OAAO,MAAM,MAAM,UAAU,SAAS,EAAE,CAAC,CAChD,CAAC,OAAmC,SAAS,KAAK,eAAe;AAEnE;EAEF,KAAK,aAEH;;AAIN,QAAO;;;;;;;;;;;AAYT,SAAgB,2BACd,MACA,kBACA,WACG;CACH,MAAM,SAAS,EAAE,GAAG,MAAM;AAE1B,MAAK,MAAM,CAAC,OAAO,SAAS,OAAO,QAAQ,iBAAiB,CAC1D,SAAQ,KAAK,OAAb;EACE,KAAK;AAEH,UAAO,OAAO;AACd;EAEF,KAAK;AAEH,OAAI,SAAS,UAAU,CAAC,KAAK,OAAO,MAAM,MAAM,UAAU,SAAS,EAAE,CAAC,CACpE,QAAO,OAAO;AAEhB;;AAMN,QAAO;;;;;;;;;;AAeT,SAAgB,sBACd,WACA,UACU;AACV,KAAI,SAAS,WAAW,EAAG,QAAO,CAAC,GAAG,UAAU;AAChD,KAAI,UAAU,WAAW,EAAG,QAAO,CAAC,GAAG,SAAS;AAChD,QAAO,CAAC,GAAG,IAAI,IAAI,CAAC,GAAG,WAAW,GAAG,SAAS,CAAC,CAAC"}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import "../elevation-B_2dRLVP.mjs";
|
|
2
|
+
import { $ as beforeUpdate, B as DefineHookOptions, G as HookRegistration, H as HookHandler, J as afterCreate, K as HookSystem, Q as beforeDelete, U as HookOperation, V as HookContext, W as HookPhase, X as afterUpdate, Y as afterDelete, Z as beforeCreate, et as createHookSystem, q as HookSystemOptions, tt as defineHook } from "../interface-Ch8HU9uM.mjs";
|
|
3
|
+
import "../types-aYB4V7uN.mjs";
|
|
4
|
+
export { type DefineHookOptions, type HookContext, type HookHandler, type HookOperation, type HookPhase, type HookRegistration, HookSystem, type HookSystemOptions, afterCreate, afterDelete, afterUpdate, beforeCreate, beforeDelete, beforeUpdate, createHookSystem, defineHook };
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { a as beforeCreate, c as createHookSystem, i as afterUpdate, l as defineHook, n as afterCreate, o as beforeDelete, r as afterDelete, s as beforeUpdate, t as HookSystem } from "../HookSystem-BsGV-j2l.mjs";
|
|
2
|
+
|
|
3
|
+
export { HookSystem, afterCreate, afterDelete, afterUpdate, beforeCreate, beforeDelete, beforeUpdate, createHookSystem, defineHook };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { i as createIdempotencyResult, n as IdempotencyResult, r as IdempotencyStore, t as IdempotencyLock } from "../interface-B01JvPVc.mjs";
|
|
2
|
+
import { r as RedisIdempotencyStoreOptions, t as RedisClient } from "../redis-D-JAeLtm.mjs";
|
|
3
|
+
import { n as MongoIdempotencyStoreOptions } from "../mongodb-JN-9JA7K.mjs";
|
|
4
|
+
import { FastifyPluginAsync } from "fastify";
|
|
5
|
+
|
|
6
|
+
//#region src/idempotency/idempotencyPlugin.d.ts
|
|
7
|
+
interface IdempotencyPluginOptions {
|
|
8
|
+
/** Enable idempotency (default: false) */
|
|
9
|
+
enabled?: boolean;
|
|
10
|
+
/** Header name for idempotency key (default: 'idempotency-key') */
|
|
11
|
+
headerName?: string;
|
|
12
|
+
/** TTL for cached responses in ms (default: 86400000 = 24h) */
|
|
13
|
+
ttlMs?: number;
|
|
14
|
+
/** Lock timeout in ms (default: 30000 = 30s) */
|
|
15
|
+
lockTimeoutMs?: number;
|
|
16
|
+
/** HTTP methods to apply idempotency to (default: ['POST', 'PUT', 'PATCH']) */
|
|
17
|
+
methods?: string[];
|
|
18
|
+
/** URL patterns to include (regex). If set, only matching URLs use idempotency */
|
|
19
|
+
include?: RegExp[];
|
|
20
|
+
/** URL patterns to exclude (regex). Excluded patterns take precedence */
|
|
21
|
+
exclude?: RegExp[];
|
|
22
|
+
/** Custom store (default: MemoryIdempotencyStore) */
|
|
23
|
+
store?: IdempotencyStore;
|
|
24
|
+
/** Retry-After header value in seconds when request is in-flight (default: 1) */
|
|
25
|
+
retryAfterSeconds?: number;
|
|
26
|
+
}
|
|
27
|
+
declare module 'fastify' {
|
|
28
|
+
interface FastifyRequest {
|
|
29
|
+
/** The idempotency key for this request (if present) */
|
|
30
|
+
idempotencyKey?: string;
|
|
31
|
+
/** Whether this response was replayed from cache */
|
|
32
|
+
idempotencyReplayed?: boolean;
|
|
33
|
+
/** @internal Full key with fingerprint for store lookups */
|
|
34
|
+
_idempotencyFullKey?: string;
|
|
35
|
+
}
|
|
36
|
+
interface FastifyInstance {
|
|
37
|
+
/** Idempotency utilities */
|
|
38
|
+
idempotency: {
|
|
39
|
+
/** Manually invalidate an idempotency key */invalidate: (key: string) => Promise<void>; /** Check if a key has a cached response */
|
|
40
|
+
has: (key: string) => Promise<boolean>;
|
|
41
|
+
/**
|
|
42
|
+
* Route-level preHandler for idempotency check + lock.
|
|
43
|
+
* Wire AFTER authenticate in the preHandler chain so that
|
|
44
|
+
* `request.user` is populated before the fingerprint is computed.
|
|
45
|
+
*
|
|
46
|
+
* `createCrudRouter` injects this automatically for mutation routes.
|
|
47
|
+
* For custom routes, add it manually:
|
|
48
|
+
* ```typescript
|
|
49
|
+
* fastify.post('/orders', {
|
|
50
|
+
* preHandler: [fastify.authenticate, fastify.idempotency.middleware],
|
|
51
|
+
* }, handler);
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
middleware: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
declare const idempotencyPlugin: FastifyPluginAsync<IdempotencyPluginOptions>;
|
|
59
|
+
declare const _default: FastifyPluginAsync<IdempotencyPluginOptions>;
|
|
60
|
+
//#endregion
|
|
61
|
+
//#region src/idempotency/stores/memory.d.ts
|
|
62
|
+
interface MemoryIdempotencyStoreOptions {
|
|
63
|
+
/** Default TTL in milliseconds (default: 86400000 = 24h) */
|
|
64
|
+
ttlMs?: number;
|
|
65
|
+
/** Cleanup interval in milliseconds (default: 60000 = 1 min) */
|
|
66
|
+
cleanupIntervalMs?: number;
|
|
67
|
+
/** Maximum entries before oldest are evicted (default: 10000) */
|
|
68
|
+
maxEntries?: number;
|
|
69
|
+
}
|
|
70
|
+
declare class MemoryIdempotencyStore implements IdempotencyStore {
|
|
71
|
+
readonly name = "memory";
|
|
72
|
+
private results;
|
|
73
|
+
private locks;
|
|
74
|
+
private ttlMs;
|
|
75
|
+
private maxEntries;
|
|
76
|
+
private cleanupInterval;
|
|
77
|
+
constructor(options?: MemoryIdempotencyStoreOptions);
|
|
78
|
+
get(key: string): Promise<IdempotencyResult | undefined>;
|
|
79
|
+
set(key: string, result: Omit<IdempotencyResult, 'key'>): Promise<void>;
|
|
80
|
+
tryLock(key: string, requestId: string, ttlMs: number): Promise<boolean>;
|
|
81
|
+
unlock(key: string, requestId: string): Promise<void>;
|
|
82
|
+
isLocked(key: string): Promise<boolean>;
|
|
83
|
+
delete(key: string): Promise<void>;
|
|
84
|
+
deleteByPrefix(prefix: string): Promise<number>;
|
|
85
|
+
findByPrefix(prefix: string): Promise<IdempotencyResult | undefined>;
|
|
86
|
+
close(): Promise<void>;
|
|
87
|
+
/** Get current stats (for debugging/monitoring) */
|
|
88
|
+
getStats(): {
|
|
89
|
+
results: number;
|
|
90
|
+
locks: number;
|
|
91
|
+
};
|
|
92
|
+
private cleanup;
|
|
93
|
+
private evictOldest;
|
|
94
|
+
}
|
|
95
|
+
//#endregion
|
|
96
|
+
export { type IdempotencyLock, type IdempotencyPluginOptions, type IdempotencyResult, type IdempotencyStore, MemoryIdempotencyStore, type MemoryIdempotencyStoreOptions, type MongoIdempotencyStoreOptions, type RedisClient, type RedisIdempotencyStoreOptions, createIdempotencyResult, _default as idempotencyPlugin, idempotencyPlugin as idempotencyPluginFn };
|
|
97
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/idempotency/idempotencyPlugin.ts","../../src/idempotency/stores/memory.ts"],"mappings":";;;;;;UA6CiB,wBAAA;EA0Bb;EAxBF,OAAA;EA6BU;EA3BV,UAAA;EA+BI;EA7BJ,KAAA;EA6BiC;EA3BjC,aAAA;EA6BU;EA3BV,OAAA;EAyCI;EAvCJ,OAAA,GAAU,MAAA;EAuCO;EArCjB,OAAA,GAAU,MAAA;EAqCgC;EAnC1C,KAAA,GAAQ,gBAAA;EAmCiE;EAjCzE,iBAAA;AAAA;AAAA;EAAA,UAIU,cAAA;IAqCgC;IAnCxC,cAAA;IAmCgE;IAjChE,mBAAA;;IAEA,mBAAA;EAAA;EAAA,UAGQ,eAAA;;IAER,WAAA;mDAEE,UAAA,GAAa,GAAA,aAAgB,OAAA,QCjEW;MDmExC,GAAA,GAAM,GAAA,aAAgB,OAAA;MCnEkB;;;;;;AAS9C;;;;;;;MDwEM,UAAA,GAAa,OAAA,EAAS,cAAA,EAAgB,KAAA,EAAO,YAAA,KAAiB,OAAA;IAAA;EAAA;AAAA;AAAA,cAQ9D,iBAAA,EAAmB,kBAAA,CAAmB,wBAAA;AAAA,cAAwB,QAAA;;;UCzFnD,6BAAA;EDgCf;EC9BA,KAAA;EDkCA;EChCA,iBAAA;EDoCA;EClCA,UAAA;AAAA;AAAA,cAGW,sBAAA,YAAkC,gBAAA;EAAA,SACpC,IAAA;EAAA,QACD,OAAA;EAAA,QACA,KAAA;EAAA,QACA,KAAA;EAAA,QACA,UAAA;EAAA,QACA,eAAA;cAEI,OAAA,GAAS,6BAAA;EAgBf,GAAA,CAAI,GAAA,WAAc,OAAA,CAAQ,iBAAA;EAa1B,GAAA,CAAI,GAAA,UAAa,MAAA,EAAQ,IAAA,CAAK,iBAAA,WAA4B,OAAA;EAS1D,OAAA,CAAQ,GAAA,UAAa,SAAA,UAAmB,KAAA,WAAgB,OAAA;EAwBxD,MAAA,CAAO,GAAA,UAAa,SAAA,WAAoB,OAAA;EAOxC,QAAA,CAAS,GAAA,WAAc,OAAA;EAavB,MAAA,CAAO,GAAA,WAAc,OAAA;EAKrB,cAAA,CAAe,MAAA,WAAiB,OAAA;EAgBhC,YAAA,CAAa,MAAA,WAAiB,OAAA,CAAQ,iBAAA;EActC,KAAA,CAAA,GAAS,OAAA;EDlFL;EC4FV,QAAA,CAAA;IAAc,OAAA;IAAiB,KAAA;EAAA;EAAA,QAOvB,OAAA;EAAA,QAgBA,WAAA;AAAA"}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import fp from "fastify-plugin";
|
|
2
|
+
import { createHash } from "crypto";
|
|
3
|
+
|
|
4
|
+
//#region src/idempotency/stores/interface.ts
|
|
5
|
+
/**
|
|
6
|
+
* Helper to create a result object
|
|
7
|
+
*/
|
|
8
|
+
function createIdempotencyResult(statusCode, body, headers, ttlMs) {
|
|
9
|
+
const now = /* @__PURE__ */ new Date();
|
|
10
|
+
return {
|
|
11
|
+
statusCode,
|
|
12
|
+
headers,
|
|
13
|
+
body,
|
|
14
|
+
createdAt: now,
|
|
15
|
+
expiresAt: new Date(now.getTime() + ttlMs)
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
//#endregion
|
|
20
|
+
//#region src/idempotency/stores/memory.ts
|
|
21
|
+
var MemoryIdempotencyStore = class {
|
|
22
|
+
name = "memory";
|
|
23
|
+
results = /* @__PURE__ */ new Map();
|
|
24
|
+
locks = /* @__PURE__ */ new Map();
|
|
25
|
+
ttlMs;
|
|
26
|
+
maxEntries;
|
|
27
|
+
cleanupInterval = null;
|
|
28
|
+
constructor(options = {}) {
|
|
29
|
+
this.ttlMs = options.ttlMs ?? 864e5;
|
|
30
|
+
this.maxEntries = options.maxEntries ?? 1e4;
|
|
31
|
+
const cleanupIntervalMs = options.cleanupIntervalMs ?? 6e4;
|
|
32
|
+
this.cleanupInterval = setInterval(() => {
|
|
33
|
+
this.cleanup();
|
|
34
|
+
}, cleanupIntervalMs);
|
|
35
|
+
if (this.cleanupInterval.unref) this.cleanupInterval.unref();
|
|
36
|
+
}
|
|
37
|
+
async get(key) {
|
|
38
|
+
const result = this.results.get(key);
|
|
39
|
+
if (!result) return void 0;
|
|
40
|
+
if (/* @__PURE__ */ new Date() > result.expiresAt) {
|
|
41
|
+
this.results.delete(key);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
async set(key, result) {
|
|
47
|
+
if (this.results.size >= this.maxEntries) this.evictOldest();
|
|
48
|
+
this.results.set(key, {
|
|
49
|
+
...result,
|
|
50
|
+
key
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
async tryLock(key, requestId, ttlMs) {
|
|
54
|
+
const existing = this.locks.get(key);
|
|
55
|
+
if (existing) if (/* @__PURE__ */ new Date() > existing.expiresAt) this.locks.delete(key);
|
|
56
|
+
else return false;
|
|
57
|
+
this.locks.set(key, {
|
|
58
|
+
key,
|
|
59
|
+
requestId,
|
|
60
|
+
lockedAt: /* @__PURE__ */ new Date(),
|
|
61
|
+
expiresAt: new Date(Date.now() + ttlMs)
|
|
62
|
+
});
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
async unlock(key, requestId) {
|
|
66
|
+
const lock = this.locks.get(key);
|
|
67
|
+
if (lock && lock.requestId === requestId) this.locks.delete(key);
|
|
68
|
+
}
|
|
69
|
+
async isLocked(key) {
|
|
70
|
+
const lock = this.locks.get(key);
|
|
71
|
+
if (!lock) return false;
|
|
72
|
+
if (/* @__PURE__ */ new Date() > lock.expiresAt) {
|
|
73
|
+
this.locks.delete(key);
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
async delete(key) {
|
|
79
|
+
this.results.delete(key);
|
|
80
|
+
this.locks.delete(key);
|
|
81
|
+
}
|
|
82
|
+
async deleteByPrefix(prefix) {
|
|
83
|
+
let count = 0;
|
|
84
|
+
for (const key of this.results.keys()) if (key.startsWith(prefix)) {
|
|
85
|
+
this.results.delete(key);
|
|
86
|
+
count++;
|
|
87
|
+
}
|
|
88
|
+
for (const key of this.locks.keys()) if (key.startsWith(prefix)) this.locks.delete(key);
|
|
89
|
+
return count;
|
|
90
|
+
}
|
|
91
|
+
async findByPrefix(prefix) {
|
|
92
|
+
const now = /* @__PURE__ */ new Date();
|
|
93
|
+
for (const [key, result] of this.results) if (key.startsWith(prefix)) {
|
|
94
|
+
if (now > result.expiresAt) {
|
|
95
|
+
this.results.delete(key);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async close() {
|
|
102
|
+
if (this.cleanupInterval) {
|
|
103
|
+
clearInterval(this.cleanupInterval);
|
|
104
|
+
this.cleanupInterval = null;
|
|
105
|
+
}
|
|
106
|
+
this.results.clear();
|
|
107
|
+
this.locks.clear();
|
|
108
|
+
}
|
|
109
|
+
/** Get current stats (for debugging/monitoring) */
|
|
110
|
+
getStats() {
|
|
111
|
+
return {
|
|
112
|
+
results: this.results.size,
|
|
113
|
+
locks: this.locks.size
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
cleanup() {
|
|
117
|
+
const now = /* @__PURE__ */ new Date();
|
|
118
|
+
for (const [key, result] of this.results) if (now > result.expiresAt) this.results.delete(key);
|
|
119
|
+
for (const [key, lock] of this.locks) if (now > lock.expiresAt) this.locks.delete(key);
|
|
120
|
+
}
|
|
121
|
+
evictOldest() {
|
|
122
|
+
const entries = Array.from(this.results.entries()).sort((a, b) => a[1].createdAt.getTime() - b[1].createdAt.getTime());
|
|
123
|
+
const toRemove = Math.max(1, Math.floor(entries.length * .1));
|
|
124
|
+
for (let i = 0; i < toRemove; i++) {
|
|
125
|
+
const entry = entries[i];
|
|
126
|
+
if (entry) this.results.delete(entry[0]);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
//#endregion
|
|
132
|
+
//#region src/idempotency/idempotencyPlugin.ts
|
|
133
|
+
/**
|
|
134
|
+
* Idempotency Plugin
|
|
135
|
+
*
|
|
136
|
+
* Duplicate request protection for mutating operations.
|
|
137
|
+
* Uses idempotency keys to ensure safe retries.
|
|
138
|
+
*
|
|
139
|
+
* ## Auth Safety
|
|
140
|
+
*
|
|
141
|
+
* The idempotency check runs as a **route-level middleware**
|
|
142
|
+
* (`idempotency.middleware`) that must be wired AFTER authentication in the
|
|
143
|
+
* preHandler chain. This ensures the fingerprint includes the real caller
|
|
144
|
+
* identity, preventing cross-user replay attacks.
|
|
145
|
+
*
|
|
146
|
+
* Arc's `createCrudRouter` does this automatically for mutation routes.
|
|
147
|
+
* For custom routes, wire it manually:
|
|
148
|
+
*
|
|
149
|
+
* ```typescript
|
|
150
|
+
* fastify.post('/orders', {
|
|
151
|
+
* preHandler: [fastify.authenticate, fastify.idempotency.middleware],
|
|
152
|
+
* }, handler);
|
|
153
|
+
* ```
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* import { idempotencyPlugin } from '@classytic/arc/idempotency';
|
|
157
|
+
*
|
|
158
|
+
* await fastify.register(idempotencyPlugin, {
|
|
159
|
+
* enabled: true,
|
|
160
|
+
* headerName: 'idempotency-key',
|
|
161
|
+
* ttlMs: 86400000, // 24 hours
|
|
162
|
+
* });
|
|
163
|
+
*
|
|
164
|
+
* // Client sends:
|
|
165
|
+
* // POST /api/orders
|
|
166
|
+
* // Idempotency-Key: order-123-abc
|
|
167
|
+
*
|
|
168
|
+
* // If same key sent again within TTL, returns cached response
|
|
169
|
+
*/
|
|
170
|
+
const HEADER_IDEMPOTENCY_REPLAYED = "x-idempotency-replayed";
|
|
171
|
+
const HEADER_IDEMPOTENCY_KEY = "x-idempotency-key";
|
|
172
|
+
const idempotencyPlugin = async (fastify, opts = {}) => {
|
|
173
|
+
const { enabled = false, headerName = "idempotency-key", ttlMs = 864e5, lockTimeoutMs = 3e4, methods = [
|
|
174
|
+
"POST",
|
|
175
|
+
"PUT",
|
|
176
|
+
"PATCH"
|
|
177
|
+
], include, exclude, store = new MemoryIdempotencyStore({ ttlMs }), retryAfterSeconds = 1 } = opts;
|
|
178
|
+
if (!enabled) {
|
|
179
|
+
fastify.decorate("idempotency", {
|
|
180
|
+
invalidate: async () => {},
|
|
181
|
+
has: async () => false,
|
|
182
|
+
middleware: async () => {}
|
|
183
|
+
});
|
|
184
|
+
fastify.decorateRequest("idempotencyKey", void 0);
|
|
185
|
+
fastify.decorateRequest("idempotencyReplayed", false);
|
|
186
|
+
fastify.log?.debug?.("Idempotency plugin disabled");
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const methodSet = new Set(methods.map((m) => m.toUpperCase()));
|
|
190
|
+
fastify.decorateRequest("idempotencyKey", void 0);
|
|
191
|
+
fastify.decorateRequest("idempotencyReplayed", false);
|
|
192
|
+
/**
|
|
193
|
+
* Check if this request should use idempotency
|
|
194
|
+
*/
|
|
195
|
+
function shouldApplyIdempotency(request) {
|
|
196
|
+
if (!methodSet.has(request.method)) return false;
|
|
197
|
+
const url = request.url;
|
|
198
|
+
if (exclude?.some((pattern) => pattern.test(url))) return false;
|
|
199
|
+
if (include && !include.some((pattern) => pattern.test(url))) return false;
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Normalize body for consistent hashing (sort keys recursively)
|
|
204
|
+
*/
|
|
205
|
+
function normalizeBody(obj) {
|
|
206
|
+
if (obj === null || typeof obj !== "object") return obj;
|
|
207
|
+
if (Array.isArray(obj)) return obj.map(normalizeBody);
|
|
208
|
+
const sorted = {};
|
|
209
|
+
const keys = Object.keys(obj).sort();
|
|
210
|
+
for (const key of keys) sorted[key] = normalizeBody(obj[key]);
|
|
211
|
+
return sorted;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Generate a fingerprint for the request (for key generation).
|
|
215
|
+
* Includes caller identity so the same idempotency key from different
|
|
216
|
+
* users doesn't replay one user's response to another.
|
|
217
|
+
*
|
|
218
|
+
* IMPORTANT: This must be called AFTER auth has populated request.user,
|
|
219
|
+
* otherwise userId falls back to 'anon' and cross-user replay is possible.
|
|
220
|
+
*/
|
|
221
|
+
function getRequestFingerprint(request) {
|
|
222
|
+
let bodyHash = "nobody";
|
|
223
|
+
if (request.body && typeof request.body === "object") {
|
|
224
|
+
const normalized = normalizeBody(request.body);
|
|
225
|
+
const bodyString = JSON.stringify(normalized);
|
|
226
|
+
bodyHash = createHash("sha256").update(bodyString).digest("hex").substring(0, 16);
|
|
227
|
+
if (request.log && request.log.debug) request.log.debug({ bodyHash }, "Generated body hash");
|
|
228
|
+
}
|
|
229
|
+
const user = request.user;
|
|
230
|
+
const userId = user?.id ?? user?._id ?? "anon";
|
|
231
|
+
return `${request.method}:${request.url}:${bodyHash}:u=${userId}`;
|
|
232
|
+
}
|
|
233
|
+
const idempotencyMiddleware = async (request, reply) => {
|
|
234
|
+
if (!shouldApplyIdempotency(request)) return;
|
|
235
|
+
const keyHeader = request.headers[headerName.toLowerCase()];
|
|
236
|
+
const idempotencyKey = typeof keyHeader === "string" ? keyHeader.trim() : void 0;
|
|
237
|
+
if (!idempotencyKey) return;
|
|
238
|
+
request.idempotencyKey = idempotencyKey;
|
|
239
|
+
const fullKey = `${idempotencyKey}:${getRequestFingerprint(request)}`;
|
|
240
|
+
const cached = await store.get(fullKey);
|
|
241
|
+
if (cached) {
|
|
242
|
+
request.idempotencyReplayed = true;
|
|
243
|
+
reply.header(HEADER_IDEMPOTENCY_REPLAYED, "true");
|
|
244
|
+
reply.header(HEADER_IDEMPOTENCY_KEY, idempotencyKey);
|
|
245
|
+
for (const [key, value] of Object.entries(cached.headers)) if (!key.startsWith("x-idempotency")) reply.header(key, value);
|
|
246
|
+
reply.code(cached.statusCode).send(cached.body);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (!await store.tryLock(fullKey, request.id, lockTimeoutMs)) {
|
|
250
|
+
reply.code(409).header("Retry-After", retryAfterSeconds.toString()).send({
|
|
251
|
+
error: "Request with this idempotency key is already in progress",
|
|
252
|
+
code: "IDEMPOTENCY_CONFLICT",
|
|
253
|
+
retryAfter: retryAfterSeconds
|
|
254
|
+
});
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
request._idempotencyFullKey = fullKey;
|
|
258
|
+
};
|
|
259
|
+
fastify.decorate("idempotency", {
|
|
260
|
+
invalidate: async (key) => {
|
|
261
|
+
await store.deleteByPrefix(`${key}:`);
|
|
262
|
+
},
|
|
263
|
+
has: async (key) => {
|
|
264
|
+
return !!await store.findByPrefix(`${key}:`);
|
|
265
|
+
},
|
|
266
|
+
middleware: idempotencyMiddleware
|
|
267
|
+
});
|
|
268
|
+
fastify.addHook("onSend", async (request, reply, payload) => {
|
|
269
|
+
if (request.idempotencyReplayed) return payload;
|
|
270
|
+
const fullKey = request._idempotencyFullKey;
|
|
271
|
+
if (!fullKey) return payload;
|
|
272
|
+
const statusCode = reply.statusCode;
|
|
273
|
+
if (statusCode < 200 || statusCode >= 300) {
|
|
274
|
+
await store.unlock(fullKey, request.id);
|
|
275
|
+
return payload;
|
|
276
|
+
}
|
|
277
|
+
const headersToCache = {};
|
|
278
|
+
const excludeHeaders = new Set([
|
|
279
|
+
"content-length",
|
|
280
|
+
"transfer-encoding",
|
|
281
|
+
"connection",
|
|
282
|
+
"keep-alive",
|
|
283
|
+
"date",
|
|
284
|
+
"set-cookie"
|
|
285
|
+
]);
|
|
286
|
+
const rawHeaders = reply.getHeaders();
|
|
287
|
+
for (const [key, value] of Object.entries(rawHeaders)) if (!excludeHeaders.has(key.toLowerCase()) && typeof value === "string") headersToCache[key] = value;
|
|
288
|
+
let body;
|
|
289
|
+
try {
|
|
290
|
+
body = typeof payload === "string" ? JSON.parse(payload) : payload;
|
|
291
|
+
} catch {
|
|
292
|
+
body = payload;
|
|
293
|
+
}
|
|
294
|
+
const result = createIdempotencyResult(statusCode, body, headersToCache, ttlMs);
|
|
295
|
+
await store.set(fullKey, result);
|
|
296
|
+
await store.unlock(fullKey, request.id);
|
|
297
|
+
reply.header(HEADER_IDEMPOTENCY_KEY, request.idempotencyKey);
|
|
298
|
+
return payload;
|
|
299
|
+
});
|
|
300
|
+
fastify.addHook("onError", async (request) => {
|
|
301
|
+
const fullKey = request._idempotencyFullKey;
|
|
302
|
+
if (fullKey) await store.unlock(fullKey, request.id);
|
|
303
|
+
});
|
|
304
|
+
fastify.addHook("onClose", async () => {
|
|
305
|
+
await store.close?.();
|
|
306
|
+
});
|
|
307
|
+
fastify.log?.debug?.({
|
|
308
|
+
headerName,
|
|
309
|
+
ttlMs,
|
|
310
|
+
methods
|
|
311
|
+
}, "Idempotency plugin enabled");
|
|
312
|
+
};
|
|
313
|
+
var idempotencyPlugin_default = fp(idempotencyPlugin, {
|
|
314
|
+
name: "arc-idempotency",
|
|
315
|
+
fastify: "5.x"
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
//#endregion
|
|
319
|
+
export { MemoryIdempotencyStore, createIdempotencyResult, idempotencyPlugin_default as idempotencyPlugin, idempotencyPlugin as idempotencyPluginFn };
|
|
320
|
+
//# sourceMappingURL=index.mjs.map
|