@devorash/node 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Devora
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,279 @@
1
+ # @devorash/node
2
+
3
+ Recording, masking and activity preferences are configured in Devora Settings.
4
+ SDK initialization overrides are ignored. New sessions retain the server's policy
5
+ snapshot across exchange and resume. Developer privacy labels take effect only
6
+ when selected in Settings; sensitive-field protection remains mandatory.
7
+ See [migration details](https://github.com/getdevora/devora-sdks/blob/main/SETTINGS.md).
8
+
9
+ Devora Backend SDK for Node.js applications. Enables secure impersonation by providing endpoints that the Devora platform can communicate with.
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ npm install @devorash/node
15
+ # or
16
+ bun add @devorash/node
17
+ ```
18
+
19
+ ## Quick Start
20
+
21
+ ```typescript
22
+ import { devoraSDK, DEVORA_ENDPOINTS } from "@devorash/node"
23
+
24
+ // Initialize SDK with your server key ID and secret key
25
+ const sdk = devoraSDK({
26
+ apiKey: process.env.DEVORA_API_KEY!,
27
+ secretKey: process.env.DEVORA_SECRET_KEY!,
28
+ orgId: "your-org-id",
29
+ debug: process.env.NODE_ENV === "development",
30
+ })
31
+
32
+ // User search endpoint
33
+ sdk.register(DEVORA_ENDPOINTS.USER_SEARCH, async (req) => {
34
+ const { term } = req.query
35
+ const users = await searchYourUsers(term)
36
+ return {
37
+ users: users.map((u) => ({
38
+ id: u.id,
39
+ email: u.email,
40
+ name: u.name,
41
+ })),
42
+ }
43
+ })
44
+
45
+ // Impersonation start endpoint
46
+ sdk.register(DEVORA_ENDPOINTS.IMPERSONATE, async (req) => {
47
+ const { id } = req.params
48
+ const { targetUser } = req.devoraContext!
49
+
50
+ // Generate a short-lived app token for the target user.
51
+ const token = await generateAuthToken(targetUser.id)
52
+
53
+ // Optionally include user settings for frontend initialization.
54
+ const userSettings = await getUserSettings(id)
55
+
56
+ return {
57
+ token,
58
+ data: {
59
+ theme: userSettings.theme,
60
+ timezone: userSettings.timezone,
61
+ },
62
+ }
63
+ })
64
+
65
+ // Session termination endpoint
66
+ sdk.register(DEVORA_ENDPOINTS.TERMINATE, async (req) => {
67
+ const { id } = req.params
68
+ const sessionId = req.sessionId ?? id
69
+
70
+ await invalidateUserSession(id, sessionId)
71
+
72
+ return { success: true }
73
+ })
74
+
75
+ export { sdk }
76
+ ```
77
+
78
+ ## Framework Integration
79
+
80
+ Use the SDK with your preferred web framework:
81
+
82
+ ### Express
83
+
84
+ ```bash
85
+ npm install @devorash/express
86
+ ```
87
+
88
+ ```typescript
89
+ import express from "express"
90
+ import { expressAdapter } from "@devorash/express"
91
+ import { sdk } from "./devora"
92
+
93
+ const app = express()
94
+ app.use("/devora", expressAdapter(sdk))
95
+ app.use(express.json()) // Parsers for other routes come afterwards.
96
+ ```
97
+
98
+ ### Hono
99
+
100
+ ```bash
101
+ npm install @devorash/hono
102
+ ```
103
+
104
+ ```typescript
105
+ import { Hono } from "hono"
106
+ import { honoAdapter } from "@devorash/hono"
107
+ import { sdk } from "./devora"
108
+
109
+ const app = new Hono()
110
+ app.route("/devora", honoAdapter(sdk))
111
+ ```
112
+
113
+ ### Fastify
114
+
115
+ ```bash
116
+ npm install @devorash/fastify
117
+ ```
118
+
119
+ ```typescript
120
+ import Fastify from "fastify"
121
+ import { fastifyAdapter } from "@devorash/fastify"
122
+ import { sdk } from "./devora"
123
+
124
+ const server = Fastify()
125
+ server.register(fastifyAdapter(sdk), { prefix: "/devora" })
126
+ ```
127
+
128
+ ## Configuration
129
+
130
+ ```typescript
131
+ const sdk = devoraSDK({
132
+ // Required
133
+ apiKey: "pk_server_live_xxx", // Your public server key ID
134
+ secretKey: "sk_server_live_xxx", // Your server secret key
135
+ orgId: "org_xxx", // Your organization ID
136
+
137
+ // Optional
138
+ debug: false, // Enable debug logging
139
+ timestampTolerance: 300, // Max clock drift in seconds (default: 5 min)
140
+ collectStats: false, // Collect request statistics
141
+ logRequests: false, // Log all requests
142
+ logger: customLogger, // Custom logger implementation
143
+ })
144
+ ```
145
+
146
+ ## Built-in Endpoints
147
+
148
+ The SDK automatically provides two built-in endpoints:
149
+
150
+ ### `/test` - Connection Test
151
+
152
+ Used by the Devora platform to verify your integration is working.
153
+
154
+ ```
155
+ GET /devora/test
156
+ ```
157
+
158
+ ### `/health` - Health Check
159
+
160
+ Returns SDK health information and registered endpoints.
161
+
162
+ ```
163
+ GET /devora/health
164
+ ```
165
+
166
+ ## Security
167
+
168
+ Every request from Devora is signed with HMAC-SHA256 (request signing v3). The
169
+ signature covers the exact path, query and body bytes, the request's direction,
170
+ a timestamp and a single-use request id. The adapters verify it before any route
171
+ lookup and reject unsigned, altered, stale or replayed requests. See
172
+ [SIGNING.md](../SIGNING.md) for the protocol and the replay-store contract.
173
+
174
+ Production deployments need a shared, atomic `replayStore`: every instance
175
+ must see every request id, and the insert must be atomic. The in-memory store
176
+ is for `environment: "development"` or `"test"` only; any other environment
177
+ refuses to start without a `replayStore`.
178
+
179
+ ```typescript
180
+ import { createClient } from "redis"
181
+ import { devoraSDK, type ReplayStore } from "@devorash/node"
182
+
183
+ const redis = await createClient({ url: process.env.REDIS_URL }).connect()
184
+
185
+ const replayStore: ReplayStore = {
186
+ async consume(namespace, requestId, expiresAt) {
187
+ // Atomic insert-if-absent that lives until expiresAt (Unix ms).
188
+ // Errors propagate: the SDK then fails closed with a 503.
189
+ const result = await redis.set(`devora:replay:${namespace}:${requestId}`, "1", {
190
+ NX: true,
191
+ PXAT: expiresAt,
192
+ })
193
+ return result === "OK"
194
+ },
195
+ }
196
+
197
+ const sdk = devoraSDK({
198
+ apiKey: process.env.DEVORA_API_KEY!,
199
+ secretKey: process.env.DEVORA_SECRET_KEY!,
200
+ orgId: process.env.DEVORA_ORG_ID!,
201
+ replayStore,
202
+ })
203
+ ```
204
+
205
+ Do not run the store with an eviction policy that can drop keys before they
206
+ expire (`maxmemory-policy` must not evict these keys). A unique-key database
207
+ insert with an expiry column works too.
208
+
209
+ ## Types
210
+
211
+ ```typescript
212
+ import type {
213
+ DevoraRequest,
214
+ DevoraResponse,
215
+ DevoraUser,
216
+ ImpersonationStartRequest,
217
+ ImpersonationStartResponse,
218
+ } from "@devorash/node"
219
+
220
+ // Request handler with types
221
+ sdk.register<
222
+ DevoraRequest<{ id: string }, {}, ImpersonationStartRequest>,
223
+ ImpersonationStartResponse
224
+ >(DEVORA_ENDPOINTS.IMPERSONATE, async (req) => {
225
+ const { id } = req.params
226
+ const { scope, expiresAt, sessionId } = req.devoraContext!
227
+ return { token: "...", data: {} }
228
+ })
229
+ ```
230
+
231
+ ## API Reference
232
+
233
+ ### `devoraSDK(config)`
234
+
235
+ Create a new SDK instance.
236
+
237
+ ### `sdk.register(path, handler, options?)`
238
+
239
+ Register an endpoint handler.
240
+
241
+ ### `sdk.getRoutes()`
242
+
243
+ Get all registered routes.
244
+
245
+ ### `sdk.getStats()`
246
+
247
+ Get request statistics (if `collectStats: true`).
248
+
249
+ ### `sdk.verifyRequest({ method, path, query, body, headers }, options?)`
250
+
251
+ Verify a signed request from Devora over its exact wire bytes: `path` is
252
+ relative to the SDK mount and still percent-encoded, `query` is everything
253
+ after the first `?`, and `body` is a `Uint8Array`. The adapters call this for
254
+ you; use it only for a custom framework integration.
255
+
256
+ ## License
257
+
258
+ MIT
259
+
260
+ ## Request body limits
261
+
262
+ Mount Express SDK routes before application-wide body parsers. The router and
263
+ middleware include a bounded JSON parser (1 MiB by default, configurable through
264
+ `maxBodySize`); compressed request bodies are rejected. The browser-session
265
+ handler uses a 4 KiB limit. A parser placed earlier can already have allocated the
266
+ entire body, so its own limit must be at least as strict for these routes.
267
+
268
+ For direct `processRequest`/`createGenericHandler` integrations, apply the same
269
+ byte limit in the host's raw-body reader before parsing or constructing
270
+ `AdapterRequest`. The generic handler's check on an already-parsed object cannot
271
+ bound earlier allocations. Configure request-size and read-timeout limits at the
272
+ HTTP server/reverse proxy as well; middleware cannot undo upstream buffering.
273
+
274
+ The impersonation guard retains at most 1,000 liveness verdicts and 64 distinct
275
+ in-flight lookups per guard. Lookup waits end after five seconds. An unresponsive
276
+ custom transport keeps its slot until it settles, preventing unlimited
277
+ background requests; saturation follows the unavailable policy (deny by default).
278
+ TTL hits never extend a verdict, and invalid unavailable-policy values are
279
+ configuration errors.
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Browser-session bridge (server side).
3
+ *
4
+ * A browser tab that inherits the customer's own authentication (cookie or
5
+ * stored token) but not Devora's per-tab capability asks the customer backend
6
+ * to restore it. The backend, having authenticated the request through its
7
+ * normal middleware, resolves the impersonation context and — only when the
8
+ * customer session is impersonated — asks Devora for a one-time resume code.
9
+ * Ordinary customer traffic never contacts Devora here.
10
+ */
11
+ import { type SessionBridgeResult } from "@devorash/core";
12
+ import type { DevoraBackendSDK } from "./types.js";
13
+ import { type ImpersonationContext } from "./middleware.js";
14
+ export interface ResolveBrowserSessionInput {
15
+ /** Trusted impersonation context for the authenticated customer session, or null. */
16
+ context: ImpersonationContext | null | undefined;
17
+ /** Non-secret tab reference supplied by the browser SDK. */
18
+ tabRef: unknown;
19
+ /** Exact browser origin of the request (from the Origin header). */
20
+ origin: string | null | undefined;
21
+ /**
22
+ * Origins allowed to use the bridge. A request that carries an Origin header is
23
+ * rejected unless this is set and includes it — there is no usable default origin
24
+ * to compare against in a backend SDK, so an unconfigured allowlist fails closed.
25
+ */
26
+ allowedOrigins?: string[];
27
+ }
28
+ /** Framework-agnostic bridge decision. Adapters wrap this in a route. */
29
+ export declare function resolveBrowserSession(sdk: DevoraBackendSDK, input: ResolveBrowserSessionInput): Promise<{
30
+ status: number;
31
+ body: SessionBridgeResult | {
32
+ error: string;
33
+ };
34
+ }>;
35
+ /** Response headers every bridge route must send. */
36
+ export declare const BROWSER_SESSION_RESPONSE_HEADERS: {
37
+ readonly "Cache-Control": "private, no-store";
38
+ readonly "Content-Type": "application/json";
39
+ };
40
+ //# sourceMappingURL=browser-session.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"browser-session.d.ts","sourceRoot":"","sources":["../src/browser-session.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,EAA0B,KAAK,mBAAmB,EAAE,MAAM,gBAAgB,CAAA;AACjF,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAA;AAClD,OAAO,EAAgC,KAAK,oBAAoB,EAAE,MAAM,iBAAiB,CAAA;AAEzF,MAAM,WAAW,0BAA0B;IAC1C,qFAAqF;IACrF,OAAO,EAAE,oBAAoB,GAAG,IAAI,GAAG,SAAS,CAAA;IAChD,4DAA4D;IAC5D,MAAM,EAAE,OAAO,CAAA;IACf,oEAAoE;IACpE,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAA;IACjC;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,EAAE,CAAA;CACzB;AAED,yEAAyE;AACzE,wBAAsB,qBAAqB,CAC1C,GAAG,EAAE,gBAAgB,EACrB,KAAK,EAAE,0BAA0B,GAC/B,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,mBAAmB,GAAG;QAAE,KAAK,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,CAAC,CA8B5E;AAED,qDAAqD;AACrD,eAAO,MAAM,gCAAgC;;;CAGnC,CAAA"}
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Browser-session bridge (server side).
3
+ *
4
+ * A browser tab that inherits the customer's own authentication (cookie or
5
+ * stored token) but not Devora's per-tab capability asks the customer backend
6
+ * to restore it. The backend, having authenticated the request through its
7
+ * normal middleware, resolves the impersonation context and — only when the
8
+ * customer session is impersonated — asks Devora for a one-time resume code.
9
+ * Ordinary customer traffic never contacts Devora here.
10
+ */
11
+ import { BROWSER_SESSION_BRIDGE } from "@devorash/core";
12
+ import { validateImpersonationContext } from "./middleware.js";
13
+ /** Framework-agnostic bridge decision. Adapters wrap this in a route. */
14
+ export async function resolveBrowserSession(sdk, input) {
15
+ if (typeof input.tabRef !== "string" ||
16
+ !BROWSER_SESSION_BRIDGE.TAB_REF_PATTERN.test(input.tabRef))
17
+ return { status: 400, body: { error: "Invalid tab reference" } };
18
+ const context = input.context;
19
+ if (context === null || context === undefined || context.isImpersonation === false)
20
+ return { status: 200, body: { status: "none" } };
21
+ // Origin only matters once we're about to mint a resume code for a real
22
+ // impersonation session; ordinary traffic through this bridge is unaffected.
23
+ // An Origin header must be listed in allowedOrigins (unconfigured fails
24
+ // closed). Devora binds every resume code to the origin that will redeem it,
25
+ // so a request without an Origin header cannot use one and gets none.
26
+ if (input.origin && (!input.allowedOrigins || !input.allowedOrigins.includes(input.origin)))
27
+ return { status: 403, body: { error: "Invalid request origin" } };
28
+ // The same strict validation as the guard: malformed or expired contexts
29
+ // never mint a resume code.
30
+ if (!input.origin || !validateImpersonationContext(context).valid)
31
+ return { status: 200, body: { status: "blocked", reason: "session_invalid" } };
32
+ const result = await sdk.createBrowserResumeCode({
33
+ sessionId: context.sessionId,
34
+ tabRef: input.tabRef,
35
+ origin: input.origin,
36
+ });
37
+ if (result === null)
38
+ return { status: 200, body: { status: "blocked", reason: "control_plane_unavailable" } };
39
+ if ("error" in result)
40
+ return { status: 200, body: { status: "blocked", reason: "session_invalid" } };
41
+ return { status: 200, body: { status: "resume", code: result.code } };
42
+ }
43
+ /** Response headers every bridge route must send. */
44
+ export const BROWSER_SESSION_RESPONSE_HEADERS = {
45
+ "Cache-Control": "private, no-store",
46
+ "Content-Type": "application/json",
47
+ };
48
+ //# sourceMappingURL=browser-session.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"browser-session.js","sourceRoot":"","sources":["../src/browser-session.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,EAAE,sBAAsB,EAA4B,MAAM,gBAAgB,CAAA;AAEjF,OAAO,EAAE,4BAA4B,EAA6B,MAAM,iBAAiB,CAAA;AAiBzF,yEAAyE;AACzE,MAAM,CAAC,KAAK,UAAU,qBAAqB,CAC1C,GAAqB,EACrB,KAAiC;IAEjC,IACC,OAAO,KAAK,CAAC,MAAM,KAAK,QAAQ;QAChC,CAAC,sBAAsB,CAAC,eAAe,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC;QAE1D,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,uBAAuB,EAAE,EAAE,CAAA;IACjE,MAAM,OAAO,GAAG,KAAK,CAAC,OAAO,CAAA;IAC7B,IAAI,OAAO,KAAK,IAAI,IAAI,OAAO,KAAK,SAAS,IAAI,OAAO,CAAC,eAAe,KAAK,KAAK;QACjF,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,EAAE,CAAA;IACjD,wEAAwE;IACxE,6EAA6E;IAC7E,wEAAwE;IACxE,6EAA6E;IAC7E,sEAAsE;IACtE,IAAI,KAAK,CAAC,MAAM,IAAI,CAAC,CAAC,KAAK,CAAC,cAAc,IAAI,CAAC,KAAK,CAAC,cAAc,CAAC,QAAQ,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;QAC1F,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,wBAAwB,EAAE,EAAE,CAAA;IAClE,yEAAyE;IACzE,4BAA4B;IAC5B,IAAI,CAAC,KAAK,CAAC,MAAM,IAAI,CAAC,4BAA4B,CAAC,OAAO,CAAC,CAAC,KAAK;QAChE,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,iBAAiB,EAAE,EAAE,CAAA;IAC/E,MAAM,MAAM,GAAG,MAAM,GAAG,CAAC,uBAAuB,CAAC;QAChD,SAAS,EAAE,OAAO,CAAC,SAAU;QAC7B,MAAM,EAAE,KAAK,CAAC,MAAM;QACpB,MAAM,EAAE,KAAK,CAAC,MAAM;KACpB,CAAC,CAAA;IACF,IAAI,MAAM,KAAK,IAAI;QAClB,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,2BAA2B,EAAE,EAAE,CAAA;IACzF,IAAI,OAAO,IAAI,MAAM;QACpB,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,iBAAiB,EAAE,EAAE,CAAA;IAC/E,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,EAAE,EAAE,CAAA;AACtE,CAAC;AAED,qDAAqD;AACrD,MAAM,CAAC,MAAM,gCAAgC,GAAG;IAC/C,eAAe,EAAE,mBAAmB;IACpC,cAAc,EAAE,kBAAkB;CACzB,CAAA"}
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Generic request handler for all framework adapters
3
+ * @module @devorash/node
4
+ */
5
+ import { type DevoraResponse } from "@devorash/core";
6
+ import type { DevoraBackendSDK, SDKRoute } from "./types.js";
7
+ /**
8
+ * A request exactly as received, for signature verification. Nothing here may
9
+ * be re-serialized: the signature covers these bytes.
10
+ */
11
+ export interface AdapterRequest {
12
+ method: string;
13
+ /** Path relative to the SDK mount, still percent-encoded exactly as received. */
14
+ path: string;
15
+ /** Raw query after the first `?`, exactly as received ("" when absent). */
16
+ query: string;
17
+ /** Exact request body bytes ("" body = empty array). */
18
+ body: Uint8Array;
19
+ headers: Record<string, string | string[] | undefined>;
20
+ }
21
+ /**
22
+ * Options for processRequest
23
+ */
24
+ export interface ProcessRequestOptions {
25
+ /** Override timestamp tolerance (defaults to SDK config) */
26
+ timestampTolerance?: number;
27
+ /** Maximum allowed body size in bytes (default: 1MB) */
28
+ maxBodySize?: number;
29
+ }
30
+ /**
31
+ * Default maximum body size (1MB)
32
+ * Framework/ingress parsers must apply this limit before buffering the body.
33
+ */
34
+ export declare const DEFAULT_MAX_BODY_SIZE: number;
35
+ /**
36
+ * Process an incoming request through the SDK.
37
+ *
38
+ * The signature is verified before any route lookup, so unauthenticated
39
+ * callers learn nothing about registered routes. Only verified bytes are
40
+ * parsed, with one parser for every framework.
41
+ */
42
+ export declare function processRequest(sdk: DevoraBackendSDK, routes: SDKRoute[], request: AdapterRequest, options?: ProcessRequestOptions): Promise<DevoraResponse>;
43
+ /**
44
+ * Strip a literal mount prefix (e.g. "/devora" or "/api/devora") from a raw
45
+ * request path. Used by middleware-style adapters where the framework does not
46
+ * strip the mount point. Devora signs the path relative to the mount.
47
+ */
48
+ export declare function stripMountPath(path: string, mountPath: string): string;
49
+ /**
50
+ * Create a generic handler function for framework adapters
51
+ */
52
+ export declare function createGenericHandler(sdk: DevoraBackendSDK, routes: SDKRoute[], options?: ProcessRequestOptions): (request: AdapterRequest) => Promise<DevoraResponse>;
53
+ //# sourceMappingURL=handler.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../src/handler.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EASN,KAAK,cAAc,EAInB,MAAM,gBAAgB,CAAA;AAEvB,OAAO,KAAK,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAA;AAE5D;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC9B,MAAM,EAAE,MAAM,CAAA;IACd,iFAAiF;IACjF,IAAI,EAAE,MAAM,CAAA;IACZ,2EAA2E;IAC3E,KAAK,EAAE,MAAM,CAAA;IACb,wDAAwD;IACxD,IAAI,EAAE,UAAU,CAAA;IAChB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,CAAC,CAAA;CACtD;AAED;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACrC,4DAA4D;IAC5D,kBAAkB,CAAC,EAAE,MAAM,CAAA;IAC3B,wDAAwD;IACxD,WAAW,CAAC,EAAE,MAAM,CAAA;CACpB;AAED;;;GAGG;AACH,eAAO,MAAM,qBAAqB,QAAc,CAAA;AAEhD;;;;;;GAMG;AACH,wBAAsB,cAAc,CACnC,GAAG,EAAE,gBAAgB,EACrB,MAAM,EAAE,QAAQ,EAAE,EAClB,OAAO,EAAE,cAAc,EACvB,OAAO,GAAE,qBAA0B,GACjC,OAAO,CAAC,cAAc,CAAC,CAwFzB;AAoBD;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAMtE;AAuHD;;GAEG;AACH,wBAAgB,oBAAoB,CACnC,GAAG,EAAE,gBAAgB,EACrB,MAAM,EAAE,QAAQ,EAAE,EAClB,OAAO,CAAC,EAAE,qBAAqB,IAEjB,SAAS,cAAc,KAAG,OAAO,CAAC,cAAc,CAAC,CAG/D"}
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Generic request handler for all framework adapters
3
+ * @module @devorash/node
4
+ */
5
+ import { DEVORA_ENDPOINTS, matchPath, createSuccessResponse, createErrorResponse, getSingleHeader, parseVerifiedJsonBody, parseVerifiedQuery, } from "@devorash/core";
6
+ /**
7
+ * Default maximum body size (1MB)
8
+ * Framework/ingress parsers must apply this limit before buffering the body.
9
+ */
10
+ export const DEFAULT_MAX_BODY_SIZE = 1024 * 1024; // 1MB
11
+ /**
12
+ * Process an incoming request through the SDK.
13
+ *
14
+ * The signature is verified before any route lookup, so unauthenticated
15
+ * callers learn nothing about registered routes. Only verified bytes are
16
+ * parsed, with one parser for every framework.
17
+ */
18
+ export async function processRequest(sdk, routes, request, options = {}) {
19
+ const { method, path, query, body, headers } = request;
20
+ // path is attacker-controlled and unbounded before a route has matched; every
21
+ // caller here that hasn't matched a route omits the second argument.
22
+ const record = (outcome, endpoint = "UNMATCHED") => {
23
+ sdk._recordRequest?.(endpoint, outcome);
24
+ };
25
+ const maxBodySize = options.maxBodySize ?? DEFAULT_MAX_BODY_SIZE;
26
+ if (!(body instanceof Uint8Array)) {
27
+ record("failure");
28
+ return createErrorResponse("Adapter must supply the raw request body bytes", "INVALID_BODY");
29
+ }
30
+ if (body.byteLength > maxBodySize) {
31
+ record("failure");
32
+ return createErrorResponse("Request body too large", "BODY_TOO_LARGE");
33
+ }
34
+ if ((method === "GET" || method === "HEAD") && body.byteLength > 0) {
35
+ record("failure");
36
+ return createErrorResponse("GET and HEAD requests must not have a body", "INVALID_BODY");
37
+ }
38
+ // Signature v3: headers, key/org, timestamp, target grammar, body digest,
39
+ // direction and single-use request id, over the exact wire bytes.
40
+ const verification = await sdk.verifyRequest({ method, path, query, body, headers }, { timestampTolerance: options.timestampTolerance });
41
+ if (!verification.valid) {
42
+ record("security_error");
43
+ return createErrorResponse(verification.error ?? "Security validation failed", verification.errorCode ?? "INVALID_SIGNATURE");
44
+ }
45
+ const route = findMatchingRoute(routes, method, path);
46
+ if (!route) {
47
+ record("failure");
48
+ return createErrorResponse("No handler found for this request", "NOT_FOUND");
49
+ }
50
+ const { params } = matchPath(route.path, path);
51
+ const parsedBody = parseVerifiedJsonBody(body, getSingleHeader(headers, "content-type"));
52
+ if (!parsedBody.ok) {
53
+ record("failure", route.path);
54
+ return createErrorResponse(parsedBody.error, "INVALID_BODY");
55
+ }
56
+ const parsedQuery = parseVerifiedQuery(query);
57
+ const devoraContextResult = buildDevoraContext(route, path, parsedBody.value, params);
58
+ if (!devoraContextResult.valid) {
59
+ record("security_error", route.path);
60
+ return createErrorResponse(devoraContextResult.error ?? "Invalid impersonation context", "INVALID_IMPERSONATION_CONTEXT");
61
+ }
62
+ // Devora never sends SECURITY_HEADERS.SESSION_ID and it isn't part of the signed
63
+ // payload, so trusting it here would let an unsigned header pick the session id.
64
+ const requestSessionId = devoraContextResult.context?.sessionId ?? getSessionIdFromRoute(route, params, parsedBody.value);
65
+ // Build Devora request object
66
+ const devoraRequest = {
67
+ method,
68
+ path,
69
+ params,
70
+ query: parsedQuery,
71
+ body: parsedBody.value,
72
+ headers,
73
+ orgId: verification.orgId ?? "",
74
+ keyId: verification.keyId ?? "",
75
+ sessionId: requestSessionId,
76
+ devoraContext: devoraContextResult.context,
77
+ };
78
+ // Execute handler
79
+ try {
80
+ const result = await route.handler(devoraRequest);
81
+ record("success", route.path);
82
+ return createSuccessResponse(result);
83
+ }
84
+ catch (error) {
85
+ if (sdk.config.debug)
86
+ console.error("[Devora] Customer handler failed", error);
87
+ record("failure", route.path);
88
+ return createErrorResponse("Customer handler failed", "HANDLER_ERROR");
89
+ }
90
+ }
91
+ /**
92
+ * Find a matching route for the given method and path
93
+ */
94
+ function findMatchingRoute(routes, method, path) {
95
+ for (const route of routes) {
96
+ if (route.method !== method.toUpperCase()) {
97
+ continue;
98
+ }
99
+ const { match } = matchPath(route.path, path);
100
+ if (match) {
101
+ return route;
102
+ }
103
+ }
104
+ return undefined;
105
+ }
106
+ /**
107
+ * Strip a literal mount prefix (e.g. "/devora" or "/api/devora") from a raw
108
+ * request path. Used by middleware-style adapters where the framework does not
109
+ * strip the mount point. Devora signs the path relative to the mount.
110
+ */
111
+ export function stripMountPath(path, mountPath) {
112
+ const normalizedMount = `/${mountPath}`.replace(/\/+/g, "/").replace(/\/$/, "");
113
+ if (!normalizedMount || normalizedMount === "/")
114
+ return path;
115
+ if (path === normalizedMount)
116
+ return "/";
117
+ if (path.startsWith(`${normalizedMount}/`))
118
+ return path.slice(normalizedMount.length);
119
+ return path;
120
+ }
121
+ function buildDevoraContext(route, path, body, params) {
122
+ if (route.path !== DEVORA_ENDPOINTS.IMPERSONATE) {
123
+ return { valid: true };
124
+ }
125
+ const bodyObject = asRecord(body);
126
+ if (!bodyObject) {
127
+ return { valid: false, error: "Impersonation request body must be an object" };
128
+ }
129
+ const sessionId = readString(bodyObject.sessionId);
130
+ if (!sessionId || sessionId.length > 128) {
131
+ return { valid: false, error: "Impersonation request is missing a valid session ID" };
132
+ }
133
+ const scope = bodyObject.scope;
134
+ if (scope !== "read" && scope !== "write") {
135
+ return { valid: false, error: "Impersonation request is missing a valid scope" };
136
+ }
137
+ const expiresAt = readFiniteNumber(bodyObject.expiresAt);
138
+ if (!expiresAt || expiresAt <= Date.now()) {
139
+ return { valid: false, error: "Impersonation request is expired or missing expiration" };
140
+ }
141
+ let targetUser = readUserInfo(bodyObject.targetUser, params.id);
142
+ if (!targetUser) {
143
+ const matchedParams = matchPath(route.path, path).params;
144
+ targetUser = readUserInfo(bodyObject.targetUser, matchedParams.id);
145
+ if (!targetUser) {
146
+ return { valid: false, error: "Impersonation request is missing target user context" };
147
+ }
148
+ }
149
+ const impersonator = readUserInfo(bodyObject.impersonator);
150
+ if (!impersonator) {
151
+ return { valid: false, error: "Impersonation request is missing impersonator context" };
152
+ }
153
+ if (bodyObject.authMethod !== "devora_impersonation") {
154
+ return { valid: false, error: "Impersonation request has an invalid authentication method" };
155
+ }
156
+ if (bodyObject.authorizationSource !== "standard" &&
157
+ bodyObject.authorizationSource !== "self_approved" &&
158
+ bodyObject.authorizationSource !== "self_approved_read" &&
159
+ bodyObject.authorizationSource !== "break_glass") {
160
+ return { valid: false, error: "Impersonation request is missing authorization context" };
161
+ }
162
+ if (typeof bodyObject.recordingAllowed !== "boolean") {
163
+ return { valid: false, error: "Impersonation request is missing recording authorization" };
164
+ }
165
+ return {
166
+ valid: true,
167
+ context: {
168
+ isImpersonation: true,
169
+ sessionId,
170
+ scope: scope,
171
+ expiresAt,
172
+ impersonator,
173
+ targetUser,
174
+ actor: { id: impersonator.id },
175
+ subject: { id: targetUser.id },
176
+ authMethod: "devora_impersonation",
177
+ authorizationSource: bodyObject.authorizationSource,
178
+ recordingAllowed: bodyObject.recordingAllowed,
179
+ },
180
+ };
181
+ }
182
+ function getSessionIdFromRoute(route, params, body) {
183
+ if (route.path === DEVORA_ENDPOINTS.TERMINATE && params.id) {
184
+ return params.id;
185
+ }
186
+ const bodyObject = asRecord(body);
187
+ return readString(bodyObject?.sessionId);
188
+ }
189
+ function asRecord(value) {
190
+ if (!value || typeof value !== "object" || Array.isArray(value))
191
+ return null;
192
+ return value;
193
+ }
194
+ function readString(value) {
195
+ return typeof value === "string" && value.trim() ? value : undefined;
196
+ }
197
+ function readFiniteNumber(value) {
198
+ if (typeof value === "number" && Number.isFinite(value))
199
+ return value;
200
+ if (typeof value === "string" && value.trim()) {
201
+ const parsed = Number(value);
202
+ if (Number.isFinite(parsed))
203
+ return parsed;
204
+ }
205
+ return undefined;
206
+ }
207
+ function readUserInfo(value, fallbackId) {
208
+ const source = asRecord(value);
209
+ const id = readString(source?.id) ?? fallbackId;
210
+ if (!id)
211
+ return null;
212
+ return {
213
+ id,
214
+ email: readString(source?.email),
215
+ name: readString(source?.name),
216
+ };
217
+ }
218
+ /**
219
+ * Create a generic handler function for framework adapters
220
+ */
221
+ export function createGenericHandler(sdk, routes, options) {
222
+ return async (request) => {
223
+ return processRequest(sdk, routes, request, options);
224
+ };
225
+ }
226
+ //# sourceMappingURL=handler.js.map