@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 +21 -0
- package/README.md +279 -0
- package/dist/browser-session.d.ts +40 -0
- package/dist/browser-session.d.ts.map +1 -0
- package/dist/browser-session.js +48 -0
- package/dist/browser-session.js.map +1 -0
- package/dist/handler.d.ts +53 -0
- package/dist/handler.d.ts.map +1 -0
- package/dist/handler.js +226 -0
- package/dist/handler.js.map +1 -0
- package/dist/hmac.d.ts +27 -0
- package/dist/hmac.d.ts.map +1 -0
- package/dist/hmac.js +53 -0
- package/dist/hmac.js.map +1 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +66 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware.d.ts +219 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +374 -0
- package/dist/middleware.js.map +1 -0
- package/dist/replay-store.d.ts +21 -0
- package/dist/replay-store.d.ts.map +1 -0
- package/dist/replay-store.js +18 -0
- package/dist/replay-store.js.map +1 -0
- package/dist/scope-config.d.ts +66 -0
- package/dist/scope-config.d.ts.map +1 -0
- package/dist/scope-config.js +199 -0
- package/dist/scope-config.js.map +1 -0
- package/dist/sdk.d.ts +11 -0
- package/dist/sdk.d.ts.map +1 -0
- package/dist/sdk.js +416 -0
- package/dist/sdk.js.map +1 -0
- package/dist/transport.d.ts +19 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +77 -0
- package/dist/transport.js.map +1 -0
- package/dist/types.d.ts +191 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +50 -0
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"}
|
package/dist/handler.js
ADDED
|
@@ -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
|