@streamotter/gateway 0.1.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +158 -0
  3. package/dist/index.d.ts +12 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +17 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/internals.d.ts +6 -0
  8. package/dist/internals.d.ts.map +1 -0
  9. package/dist/internals.js +6 -0
  10. package/dist/internals.js.map +1 -0
  11. package/dist/management/index.d.ts +26 -0
  12. package/dist/management/index.d.ts.map +1 -0
  13. package/dist/management/index.js +354 -0
  14. package/dist/management/index.js.map +1 -0
  15. package/dist/runtime/budget.d.ts +24 -0
  16. package/dist/runtime/budget.d.ts.map +1 -0
  17. package/dist/runtime/budget.js +56 -0
  18. package/dist/runtime/budget.js.map +1 -0
  19. package/dist/runtime/core.d.ts +67 -0
  20. package/dist/runtime/core.d.ts.map +1 -0
  21. package/dist/runtime/core.js +34 -0
  22. package/dist/runtime/core.js.map +1 -0
  23. package/dist/runtime/gateway.d.ts +117 -0
  24. package/dist/runtime/gateway.d.ts.map +1 -0
  25. package/dist/runtime/gateway.js +881 -0
  26. package/dist/runtime/gateway.js.map +1 -0
  27. package/dist/runtime/identity.d.ts +28 -0
  28. package/dist/runtime/identity.d.ts.map +1 -0
  29. package/dist/runtime/identity.js +92 -0
  30. package/dist/runtime/identity.js.map +1 -0
  31. package/dist/runtime/session.d.ts +49 -0
  32. package/dist/runtime/session.d.ts.map +1 -0
  33. package/dist/runtime/session.js +299 -0
  34. package/dist/runtime/session.js.map +1 -0
  35. package/dist/runtime/subscription.d.ts +65 -0
  36. package/dist/runtime/subscription.d.ts.map +1 -0
  37. package/dist/runtime/subscription.js +482 -0
  38. package/dist/runtime/subscription.js.map +1 -0
  39. package/dist/runtime/traces.d.ts +26 -0
  40. package/dist/runtime/traces.d.ts.map +1 -0
  41. package/dist/runtime/traces.js +98 -0
  42. package/dist/runtime/traces.js.map +1 -0
  43. package/dist/runtime/util.d.ts +50 -0
  44. package/dist/runtime/util.d.ts.map +1 -0
  45. package/dist/runtime/util.js +148 -0
  46. package/dist/runtime/util.js.map +1 -0
  47. package/dist/sources/fixture.d.ts +27 -0
  48. package/dist/sources/fixture.d.ts.map +1 -0
  49. package/dist/sources/fixture.js +89 -0
  50. package/dist/sources/fixture.js.map +1 -0
  51. package/dist/sources/kafka.d.ts +63 -0
  52. package/dist/sources/kafka.d.ts.map +1 -0
  53. package/dist/sources/kafka.js +418 -0
  54. package/dist/sources/kafka.js.map +1 -0
  55. package/dist/sources/kafkajs-patch.d.ts +11 -0
  56. package/dist/sources/kafkajs-patch.d.ts.map +1 -0
  57. package/dist/sources/kafkajs-patch.js +34 -0
  58. package/dist/sources/kafkajs-patch.js.map +1 -0
  59. package/dist/sources/types.d.ts +46 -0
  60. package/dist/sources/types.d.ts.map +1 -0
  61. package/dist/sources/types.js +2 -0
  62. package/dist/sources/types.js.map +1 -0
  63. package/dist/transport/socketio.d.ts +44 -0
  64. package/dist/transport/socketio.d.ts.map +1 -0
  65. package/dist/transport/socketio.js +80 -0
  66. package/dist/transport/socketio.js.map +1 -0
  67. package/dist/transport/types.d.ts +10 -0
  68. package/dist/transport/types.d.ts.map +1 -0
  69. package/dist/transport/types.js +2 -0
  70. package/dist/transport/types.js.map +1 -0
  71. package/package.json +61 -0
  72. package/src/index.ts +20 -0
  73. package/src/internals.ts +5 -0
  74. package/src/management/index.ts +371 -0
  75. package/src/runtime/budget.ts +60 -0
  76. package/src/runtime/core.ts +101 -0
  77. package/src/runtime/gateway.ts +892 -0
  78. package/src/runtime/identity.ts +99 -0
  79. package/src/runtime/session.ts +329 -0
  80. package/src/runtime/subscription.ts +531 -0
  81. package/src/runtime/traces.ts +102 -0
  82. package/src/runtime/util.ts +157 -0
  83. package/src/sources/fixture.ts +95 -0
  84. package/src/sources/kafka.ts +440 -0
  85. package/src/sources/kafkajs-patch.ts +41 -0
  86. package/src/sources/types.ts +42 -0
  87. package/src/transport/socketio.ts +125 -0
  88. package/src/transport/types.ts +10 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Orca Solutions
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,158 @@
1
+ # @streamotter/gateway
2
+
3
+ The StreamOtter Node.js gateway. It consumes Kafka (or deterministic fixtures during development), runs **your** handlers to decide identity, access, public payload, and authoritative state, and delivers state channels to browsers using [`@streamotter/client`](https://www.npmjs.com/package/@streamotter/client). Each subscription gets a snapshot, then full-state updates ordered by revision, with bounded queues and explicit `live`/`stale` states.
4
+
5
+ > **Release candidate.** `0.1.0-rc.1` is published under the `next` tag. Package versions follow SemVer independently of the V1 protocol and `configVersion: 1`.
6
+
7
+ ```bash
8
+ npm install @streamotter/gateway@next
9
+ ```
10
+
11
+ Requires Node.js 24 or later. ESM only, with TypeScript declarations included. Kafka access uses KafkaJS 2.2.4 behind an internal adapter. Browser delivery uses Socket.IO 4.8.3 over WebSocket.
12
+
13
+ Most projects run the gateway through [`@streamotter/cli`](https://www.npmjs.com/package/@streamotter/cli) (`streamotter dev` / `streamotter start`), which loads a `streamotter.json` and a handler module. The same pieces work programmatically, as shown below.
14
+
15
+ ## Configuration
16
+
17
+ `streamotter.json` declares sources, JSON schemas, and channels. It never contains code or resolved secrets:
18
+
19
+ ```json
20
+ {
21
+ "configVersion": 1,
22
+ "projectId": "orders-app",
23
+ "gateway": { "host": "127.0.0.1", "port": 7400, "path": "/streamotter/socket.io", "allowedOrigins": ["https://app.example.com"] },
24
+ "connections": {
25
+ "cluster": {
26
+ "brokers": ["kafka-1.example.com:9093"],
27
+ "tls": { "caFile": "certs/ca.pem" },
28
+ "sasl": { "mechanism": "scram-sha-512", "username": { "env": "KAFKA_USERNAME" }, "password": { "env": "KAFKA_PASSWORD" } }
29
+ }
30
+ },
31
+ "sources": {
32
+ "orders": {
33
+ "kind": "kafka", "generation": "orders-1", "connectionRef": "cluster", "topics": ["orders.status"],
34
+ "consumerGroup": "orders-app-streamotter", "codec": "json", "startFrom": "latest"
35
+ }
36
+ },
37
+ "schemas": {
38
+ "OrderParams": {
39
+ "type": "object", "additionalProperties": false, "required": ["orderId"],
40
+ "properties": { "orderId": { "type": "string", "minLength": 1, "maxLength": 64 } }
41
+ },
42
+ "OrderState": {
43
+ "type": "object", "additionalProperties": false, "required": ["orderId", "status", "progress"],
44
+ "properties": {
45
+ "orderId": { "type": "string", "minLength": 1, "maxLength": 64 },
46
+ "status": { "type": "string", "enum": ["queued", "processing", "done"] },
47
+ "progress": { "type": "integer", "minimum": 0, "maximum": 100 }
48
+ }
49
+ }
50
+ },
51
+ "channels": {
52
+ "orderStatus": {
53
+ "version": 1, "source": "orders", "paramsSchema": "OrderParams", "payloadSchema": "OrderState",
54
+ "handlersRef": "orderStatus", "delivery": { "kind": "state", "overflow": "resync" }
55
+ }
56
+ }
57
+ }
58
+ ```
59
+
60
+ Credentials are environment references. CA paths resolve relative to the configuration file. Give each source its own consumer group, and change `generation` whenever you recreate topics or change clusters. Run `streamotter generate` to produce the `AppChannels` type used below.
61
+
62
+ ## Handlers
63
+
64
+ | Handler | Receives | Returns |
65
+ | --- | --- | --- |
66
+ | `authenticate` | The browser's token and verified `Origin` | A `Principal` (`subject`, `tenantId`, `sessionId`, future `expiresAt`, `claims`), or `null` to reject |
67
+ | `authorize` | The principal and validated channel parameters | `true` to allow. Anything else is `FORBIDDEN`. |
68
+ | `map` | A decoded source record | The public states it produces, or `[]` to filter it out |
69
+ | `snapshot` | The principal and parameters | `{ revision, data }`: the authoritative current state |
70
+
71
+ ```ts
72
+ import type { HandlerRegistry } from "@streamotter/gateway";
73
+ import type { AppChannels, OrderState } from "./generated/streamotter.generated.js";
74
+ import { orders, sessions } from "./app.js"; // your application's session store and database
75
+
76
+ interface OrderEvent { accountId: string; version: number; order: OrderState }
77
+
78
+ export const handlers: HandlerRegistry<AppChannels> = {
79
+ async authenticate({ token, signal }) {
80
+ const session = await sessions.verify(token, signal);
81
+ if (session === null) return null;
82
+ return { subject: session.userId, tenantId: session.accountId, sessionId: session.id, expiresAt: session.expiresAt, claims: {} };
83
+ },
84
+ channels: {
85
+ orderStatus: {
86
+ authorize: ({ principal, params, signal }) => orders.isVisibleTo(principal.tenantId, principal.subject, params.orderId, signal),
87
+ map({ record }) {
88
+ const event = record.value as unknown as OrderEvent; // the Kafka value, decoded as JSON
89
+ return [{ tenantId: event.accountId, params: { orderId: event.order.orderId }, revision: String(event.version), data: event.order }];
90
+ },
91
+ async snapshot({ principal, params, signal }) {
92
+ const row = await orders.read(principal.tenantId, params.orderId, signal);
93
+ return { revision: String(row.version), data: row.order };
94
+ }
95
+ }
96
+ }
97
+ };
98
+ ```
99
+
100
+ Every handler receives an `AbortSignal` and a `requestId`. Stop work when the signal aborts: results that arrive after a timeout, unsubscribe, or revocation are ignored. A handler that throws fails closed.
101
+
102
+ ## The snapshot and revision contract
103
+
104
+ StreamOtter can only be as correct as the state your handlers describe:
105
+
106
+ - **Revisions** are canonical unsigned decimal strings (`"0"`, `"42"`, up to 39 digits), compared numerically. They increase for every change to a channel instance and never reset, even when an entity is recreated.
107
+ - **Snapshots and mapped updates describe the same progression.** Every change newer than a snapshot must eventually reach the source, for example through a transactional outbox. The gateway captures updates before calling `snapshot` and releases only those newer than the snapshot's revision.
108
+ - **One instance, one partition.** Changes to one channel instance must arrive in revision order from one Kafka partition (key your records by entity).
109
+ - **Full state, not deltas.** Each update replaces the previous state. Represent deletion as explicit state. A Kafka tombstone (null value) has no delete meaning; it pauses the source like any other invalid record.
110
+ - **Same public state for every authorized reader.** The routing identity is channel, version, the mapper's `tenantId`, and canonical parameters. Don't redact per user in `snapshot`; use separate channels or parameters for different views.
111
+ - **Invalid records pause, never skip.** Invalid JSON, an invalid mapped payload or revision, or a handler failure pauses the source at that record, and its subscriptions go `stale`. Fix the cause, then call `gateway.resumeSource(sourceId)` to retry the same record.
112
+
113
+ ## Run it
114
+
115
+ ```ts
116
+ import { readFile } from "node:fs/promises";
117
+ import { createGateway, defineProject } from "@streamotter/gateway";
118
+ import type { AppChannels } from "./generated/streamotter.generated.js";
119
+ import { handlers } from "./handlers.js";
120
+
121
+ const config = defineProject<AppChannels>(JSON.parse(await readFile("streamotter.json", "utf8")));
122
+ const gateway = createGateway({ config, handlers, mode: "production" });
123
+ const { origin, path } = await gateway.start(); // resolves when every source has joined its group
124
+ console.log(`StreamOtter listening on ${origin} (${path})`);
125
+ process.once("SIGTERM", () => void gateway.stop({ timeoutMs: 10_000 }));
126
+ ```
127
+
128
+ - `defineProject` validates the configuration synchronously and throws `CONFIG_INVALID` with every issue.
129
+ - `start()` rolls back and rejects if startup fails or takes longer than 30 seconds. A stopped gateway cannot restart; create a new one.
130
+ - `mode: "production"` refuses fixture sources, plaintext Kafka, and the `development` option, and requires an exact browser `Origin` on every connection. `mode: "development"` accepts `development: { principals, fixtures }` for local work.
131
+ - `configDir` (optional) is where relative CA paths resolve; it defaults to the working directory (the CLI uses the configuration file's directory). `logger` (optional) receives redacted operator diagnostics: never credentials or payloads.
132
+
133
+ ## Revoke access
134
+
135
+ Update your durable session or authorization policy first, then tell the gateway:
136
+
137
+ ```ts
138
+ await sessions.revoke(sessionId); // your store: reconnecting now fails
139
+ const { closedSubscriptions, closedConnections } =
140
+ await gateway.revoke({ kind: "session", tenantId, sessionId }); // or { kind: "subject", tenantId, subject }
141
+ await gateway.revoke({ kind: "channel", tenantId, subject, channel: "orderStatus", channelVersion: 1, params: { orderId } });
142
+ ```
143
+
144
+ Revocation takes effect immediately, including while `authorize` or `snapshot` is still pending. Unsent frames are dropped; bytes already sent cannot be recalled. V1 keeps no revocation database, which is why your own policy must change first.
145
+
146
+ ## Production boundary
147
+
148
+ Run **exactly one gateway per project** (V1 has no multi-gateway coordination), behind a TLS-terminating proxy that forwards WebSocket upgrades and the browser's `Origin`. There is no management or health endpoint in production. See [Running StreamOtter](https://github.com/jfricano/StreamOtter/blob/main/docs/DEPLOYMENT.md) for the verified reverse-proxy recipe and the [Kafka support matrix](https://github.com/jfricano/StreamOtter/blob/main/docs/IMPLEMENTATION_STATUS.md#kafka-support-matrix-kafkajs-224--apache-kafka-412): TLS, and TLS with SASL PLAIN and SCRAM-SHA-256/512, are verified against Apache Kafka 4.1.2. Other broker versions and managed services are unverified.
149
+
150
+ `@streamotter/gateway/management` is the development-only management API used by `streamotter dev`; it refuses production gateways. `@streamotter/gateway/internals` exists for StreamOtter's own CLI and is not a stable API.
151
+
152
+ ## More
153
+
154
+ - [V1 API specification](https://github.com/jfricano/StreamOtter/blob/main/docs/V1_API.md): handlers and lifecycle (§3), synchronization (§5), source progress and limits (§6), access (§7)
155
+ - [Reference application](https://github.com/jfricano/StreamOtter/tree/main/examples/order-dashboard): fixture and Kafka handlers for a real app
156
+ - [Repository](https://github.com/jfricano/StreamOtter) · [Issues](https://github.com/jfricano/StreamOtter/issues)
157
+
158
+ MIT License © 2026 Orca Solutions
@@ -0,0 +1,12 @@
1
+ import { type ChannelMap, type Gateway, type GatewayOptions, type ProjectConfig } from "@streamotter/contracts";
2
+ export type * from "@streamotter/contracts";
3
+ export { StreamOtterError, validateProjectConfig } from "@streamotter/contracts";
4
+ export { consoleLogger, silentLogger } from "./runtime/util.ts";
5
+ /**
6
+ * Validates configuration structure and references synchronously. Secret
7
+ * resolution and connectivity belong to gateway startup. Throws CONFIG_INVALID.
8
+ */
9
+ export declare function defineProject<C extends ChannelMap>(config: ProjectConfig<C>): ProjectConfig<C>;
10
+ /** Constructs a gateway without opening connections; call start() to listen and consume. */
11
+ export declare function createGateway<C extends ChannelMap>(options: GatewayOptions<C>): Gateway;
12
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAA4B,KAAK,UAAU,EAAE,KAAK,OAAO,EAAE,KAAK,cAAc,EAAE,KAAK,aAAa,EAAE,MAAM,wBAAwB,CAAC;AAG1I,mBAAmB,wBAAwB,CAAC;AAC5C,OAAO,EAAE,gBAAgB,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAC;AACjF,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAEhE;;;GAGG;AACH,wBAAgB,aAAa,CAAC,CAAC,SAAS,UAAU,EAAE,MAAM,EAAE,aAAa,CAAC,CAAC,CAAC,GAAG,aAAa,CAAC,CAAC,CAAC,CAG9F;AAED,4FAA4F;AAC5F,wBAAgB,aAAa,CAAC,CAAC,SAAS,UAAU,EAAE,OAAO,EAAE,cAAc,CAAC,CAAC,CAAC,GAAG,OAAO,CAEvF"}
package/dist/index.js ADDED
@@ -0,0 +1,17 @@
1
+ import { assertValidProjectConfig } from "@streamotter/contracts";
2
+ import { createGatewayRuntime } from "./runtime/gateway.js";
3
+ export { StreamOtterError, validateProjectConfig } from "@streamotter/contracts";
4
+ export { consoleLogger, silentLogger } from "./runtime/util.js";
5
+ /**
6
+ * Validates configuration structure and references synchronously. Secret
7
+ * resolution and connectivity belong to gateway startup. Throws CONFIG_INVALID.
8
+ */
9
+ export function defineProject(config) {
10
+ assertValidProjectConfig(config);
11
+ return config;
12
+ }
13
+ /** Constructs a gateway without opening connections; call start() to listen and consume. */
14
+ export function createGateway(options) {
15
+ return createGatewayRuntime(options).gateway;
16
+ }
17
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,wBAAwB,EAA0E,MAAM,wBAAwB,CAAC;AAC1I,OAAO,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AAG5D,OAAO,EAAE,gBAAgB,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAC;AACjF,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAEhE;;;GAGG;AACH,MAAM,UAAU,aAAa,CAAuB,MAAwB;IAC1E,wBAAwB,CAAC,MAAM,CAAC,CAAC;IACjC,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,4FAA4F;AAC5F,MAAM,UAAU,aAAa,CAAuB,OAA0B;IAC5E,OAAO,oBAAoB,CAAC,OAAO,CAAC,CAAC,OAAO,CAAC;AAC/C,CAAC"}
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Internal access shared by StreamOtter's own CLI, management server, and tests.
3
+ * Not a stable public API; applications should use createGateway().
4
+ */
5
+ export { createGatewayRuntime, getGatewayInternals, type GatewayInternals, type InternalGatewayOptions } from "./runtime/gateway.ts";
6
+ //# sourceMappingURL=internals.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"internals.d.ts","sourceRoot":"","sources":["../src/internals.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,KAAK,gBAAgB,EAAE,KAAK,sBAAsB,EAAE,MAAM,sBAAsB,CAAC"}
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Internal access shared by StreamOtter's own CLI, management server, and tests.
3
+ * Not a stable public API; applications should use createGateway().
4
+ */
5
+ export { createGatewayRuntime, getGatewayInternals } from "./runtime/gateway.js";
6
+ //# sourceMappingURL=internals.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"internals.js","sourceRoot":"","sources":["../src/internals.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,EAAE,oBAAoB,EAAE,mBAAmB,EAAsD,MAAM,sBAAsB,CAAC"}
@@ -0,0 +1,26 @@
1
+ import { type Gateway } from "@streamotter/contracts";
2
+ import { type GatewayInternals } from "../runtime/gateway.ts";
3
+ export interface ManagementServerOptions {
4
+ gateway: Gateway;
5
+ /** Loopback by default. */
6
+ host?: string;
7
+ port?: number;
8
+ /** Per-run bearer token; generated when omitted. */
9
+ token?: string;
10
+ /** Built workbench assets to serve from the same origin; null disables the UI. */
11
+ workbenchDir?: string | null;
12
+ }
13
+ export interface ManagementServer {
14
+ readonly origin: string;
15
+ readonly token: string;
16
+ close(): Promise<void>;
17
+ }
18
+ /**
19
+ * Local development management API and workbench host. Every /management/v1
20
+ * operation requires the per-run bearer token. Browser requests must carry the
21
+ * exact workbench Origin (or, for same-origin GETs, a same-origin Referer). No
22
+ * CORS is ever granted. Refuses to start for production gateways.
23
+ */
24
+ export declare function startManagementServer(options: ManagementServerOptions): Promise<ManagementServer>;
25
+ export type { GatewayInternals };
26
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/management/index.ts"],"names":[],"mappings":"AAKA,OAAO,EAEkC,KAAK,OAAO,EACpD,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAuB,KAAK,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAGnF,MAAM,WAAW,uBAAuB;IACtC,OAAO,EAAE,OAAO,CAAC;IACjB,2BAA2B;IAC3B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oDAAoD;IACpD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,kFAAkF;IAClF,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AA0FD;;;;;GAKG;AACH,wBAAsB,qBAAqB,CAAC,OAAO,EAAE,uBAAuB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAqPvG;AAED,YAAY,EAAE,gBAAgB,EAAE,CAAC"}
@@ -0,0 +1,354 @@
1
+ import { randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { readFile, realpath, stat } from "node:fs/promises";
3
+ import { createServer } from "node:http";
4
+ import { extname, join, resolve as resolvePath, sep } from "node:path";
5
+ import { canonicalJsonPretty, CAPABILITIES, DEFAULT_MANAGEMENT_PORT, isPlainObject, streamError, StreamOtterError, validateProjectConfig } from "@streamotter/contracts";
6
+ import { getGatewayInternals } from "../runtime/gateway.js";
7
+ import { newId, sha256Hex, TokenBucket } from "../runtime/util.js";
8
+ const MAX_BODY_BYTES = 1_048_576;
9
+ const STATUS_BY_CODE = {
10
+ INVALID_REQUEST: 400, INVALID_PARAMS: 400, CONFIG_INVALID: 400, UNSUPPORTED_CAPABILITY: 400,
11
+ UNAUTHENTICATED: 401, FORBIDDEN: 403, CHANNEL_NOT_FOUND: 404, TRACE_CURSOR_EXPIRED: 410,
12
+ OVERLOADED: 429, SOURCE_UNAVAILABLE: 503, TIMEOUT: 504
13
+ };
14
+ const CONTENT_TYPES = {
15
+ ".html": "text/html; charset=utf-8",
16
+ ".js": "text/javascript; charset=utf-8",
17
+ ".css": "text/css; charset=utf-8",
18
+ ".svg": "image/svg+xml",
19
+ ".png": "image/png",
20
+ ".ico": "image/x-icon",
21
+ ".json": "application/json; charset=utf-8",
22
+ ".map": "application/json; charset=utf-8"
23
+ };
24
+ class HttpError extends Error {
25
+ status;
26
+ error;
27
+ constructor(status, code, message, details) {
28
+ super(message ?? code);
29
+ this.status = status;
30
+ const options = {};
31
+ if (message !== undefined)
32
+ options.message = message;
33
+ if (details !== undefined)
34
+ options.details = details;
35
+ this.error = streamError(code, options);
36
+ }
37
+ }
38
+ function tokensEqual(expected, provided) {
39
+ const a = Buffer.from(expected);
40
+ const b = Buffer.from(provided);
41
+ return a.length === b.length && timingSafeEqual(a, b);
42
+ }
43
+ async function readJsonBody(request) {
44
+ const declared = Number(request.headers["content-length"] ?? "0");
45
+ if (declared > MAX_BODY_BYTES)
46
+ throw new HttpError(413, "INVALID_REQUEST", "The request body exceeds 1 MiB.");
47
+ const chunks = [];
48
+ let size = 0;
49
+ for await (const chunk of request) {
50
+ size += chunk.length;
51
+ if (size > MAX_BODY_BYTES)
52
+ throw new HttpError(413, "INVALID_REQUEST", "The request body exceeds 1 MiB.");
53
+ chunks.push(chunk);
54
+ }
55
+ if (size === 0)
56
+ throw new HttpError(400, "INVALID_REQUEST", "A JSON body is required.");
57
+ const type = request.headers["content-type"] ?? "";
58
+ if (!/^application\/json\b/i.test(type))
59
+ throw new HttpError(400, "INVALID_REQUEST", "Content-Type must be application/json.");
60
+ try {
61
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
62
+ }
63
+ catch {
64
+ throw new HttpError(400, "INVALID_REQUEST", "The body is not valid JSON.");
65
+ }
66
+ }
67
+ /** Requires an object with exactly the listed keys (optional keys may be absent). */
68
+ function shape(value, required, optional = []) {
69
+ if (!isPlainObject(value))
70
+ throw new HttpError(400, "INVALID_REQUEST", "The body must be a JSON object.");
71
+ for (const key of Object.keys(value)) {
72
+ if (!required.includes(key) && !optional.includes(key))
73
+ throw new HttpError(400, "INVALID_REQUEST", `Unknown field "${key.slice(0, 64)}".`);
74
+ }
75
+ for (const key of required) {
76
+ if (!Object.hasOwn(value, key))
77
+ throw new HttpError(400, "INVALID_REQUEST", `"${key}" is required.`);
78
+ }
79
+ return value;
80
+ }
81
+ function requireString(value, name) {
82
+ if (typeof value !== "string" || value.length === 0 || value.length > 256)
83
+ throw new HttpError(400, "INVALID_REQUEST", `${name} must be a non-empty string.`);
84
+ return value;
85
+ }
86
+ function mapError(error) {
87
+ if (error instanceof HttpError)
88
+ return { status: error.status, error: error.error };
89
+ if (error instanceof StreamOtterError) {
90
+ const status = typeof error.details?.["status"] === "number" ? error.details["status"] : STATUS_BY_CODE[error.code] ?? 500;
91
+ const { status: _omit, ...details } = (error.details ?? {});
92
+ const options = { message: error.message, retryable: error.retryable };
93
+ if (Object.keys(details).length > 0)
94
+ options.details = details;
95
+ return { status, error: streamError(error.code, options) };
96
+ }
97
+ return { status: 500, error: streamError("INTERNAL") };
98
+ }
99
+ /**
100
+ * Local development management API and workbench host. Every /management/v1
101
+ * operation requires the per-run bearer token. Browser requests must carry the
102
+ * exact workbench Origin (or, for same-origin GETs, a same-origin Referer). No
103
+ * CORS is ever granted. Refuses to start for production gateways.
104
+ */
105
+ export async function startManagementServer(options) {
106
+ const internals = getGatewayInternals(options.gateway);
107
+ if (internals.mode !== "development") {
108
+ throw new StreamOtterError("FORBIDDEN", { message: "The management API is only available in development mode." });
109
+ }
110
+ const token = options.token ?? randomBytes(24).toString("base64url");
111
+ const host = options.host ?? "127.0.0.1";
112
+ const workbenchDir = options.workbenchDir === undefined || options.workbenchDir === null ? null : await realpath(options.workbenchDir).catch(() => null);
113
+ const bucket = new TokenBucket(100, 200);
114
+ let origin = "";
115
+ const server = createServer((request, response) => {
116
+ const requestId = newId();
117
+ response.setHeader("X-Request-Id", requestId);
118
+ response.setHeader("Cache-Control", "no-store");
119
+ response.setHeader("X-Content-Type-Options", "nosniff");
120
+ response.setHeader("Referrer-Policy", "same-origin");
121
+ response.setHeader("X-Frame-Options", "DENY");
122
+ handle(request, response, requestId).catch(error => {
123
+ const mapped = mapError(error);
124
+ if (mapped.status === 500)
125
+ internals.logger.error("Management request failed", { requestId, error: String(error?.name ?? "Error") });
126
+ send(response, mapped.status, { ok: false, requestId, error: { ...mapped.error, requestId } });
127
+ });
128
+ });
129
+ function send(response, status, body) {
130
+ if (response.headersSent) {
131
+ response.end();
132
+ return;
133
+ }
134
+ response.statusCode = status;
135
+ response.setHeader("Content-Type", "application/json; charset=utf-8");
136
+ response.end(JSON.stringify(body));
137
+ }
138
+ function checkBrowserOrigin(request) {
139
+ const requestOrigin = request.headers.origin;
140
+ if (requestOrigin !== undefined) {
141
+ if (requestOrigin !== origin)
142
+ throw new HttpError(403, "FORBIDDEN", "Cross-origin management requests are not allowed.");
143
+ return;
144
+ }
145
+ const referer = request.headers.referer;
146
+ if (referer !== undefined) {
147
+ if (request.method !== "GET" || !(referer === origin || referer.startsWith(`${origin}/`))) {
148
+ throw new HttpError(403, "FORBIDDEN", "Cross-origin management requests are not allowed.");
149
+ }
150
+ }
151
+ }
152
+ function query(url, allowed) {
153
+ const values = {};
154
+ for (const [key, value] of url.searchParams) {
155
+ if (!allowed.includes(key))
156
+ throw new HttpError(400, "INVALID_REQUEST", `Unknown query parameter "${key.slice(0, 64)}".`);
157
+ if (Object.hasOwn(values, key))
158
+ throw new HttpError(400, "INVALID_REQUEST", `Duplicate query parameter "${key.slice(0, 64)}".`);
159
+ values[key] = value;
160
+ }
161
+ return values;
162
+ }
163
+ async function handle(request, response, requestId) {
164
+ const url = new URL(request.url ?? "/", "http://management.invalid");
165
+ if (!url.pathname.startsWith("/management/")) {
166
+ await serveStatic(request, response, url.pathname);
167
+ return;
168
+ }
169
+ checkBrowserOrigin(request);
170
+ const authorization = request.headers.authorization ?? "";
171
+ const provided = /^Bearer (.+)$/.exec(authorization)?.[1] ?? "";
172
+ if (!tokensEqual(token, provided))
173
+ throw new HttpError(401, "UNAUTHENTICATED", "A valid management bearer token is required.");
174
+ if (!bucket.take())
175
+ throw new HttpError(429, "OVERLOADED", "Too many management requests.");
176
+ const route = `${request.method ?? "GET"} ${url.pathname}`;
177
+ const ok = (data) => send(response, 200, { ok: true, requestId, data });
178
+ const noQuery = () => query(url, []);
179
+ switch (route) {
180
+ case "GET /management/v1/capabilities":
181
+ noQuery();
182
+ return ok(CAPABILITIES);
183
+ case "GET /management/v1/health":
184
+ noQuery();
185
+ return ok(internals.health());
186
+ case "GET /management/v1/sources":
187
+ noQuery();
188
+ return ok({ items: internals.sources() });
189
+ case "GET /management/v1/channels":
190
+ noQuery();
191
+ return ok({ items: internals.channels() });
192
+ case "GET /management/v1/config":
193
+ noQuery();
194
+ return ok({ config: internals.config, fingerprint: internals.fingerprint });
195
+ case "GET /management/v1/traces": {
196
+ const q = query(url, ["limit", "cursor", "sourceId", "channel", "outcome"]);
197
+ let limit = 100;
198
+ if (q["limit"] !== undefined) {
199
+ if (!/^\d{1,3}$/.test(q["limit"]))
200
+ throw new HttpError(400, "INVALID_REQUEST", "limit must be an integer from 1 to 500.");
201
+ limit = Number(q["limit"]);
202
+ if (limit < 1 || limit > 500)
203
+ throw new HttpError(400, "INVALID_REQUEST", "limit must be an integer from 1 to 500.");
204
+ }
205
+ const outcome = q["outcome"];
206
+ if (outcome !== undefined && !["ok", "filtered", "rejected", "failed"].includes(outcome)) {
207
+ throw new HttpError(400, "INVALID_REQUEST", "outcome must be ok, filtered, rejected, or failed.");
208
+ }
209
+ const page = internals.traces({
210
+ limit,
211
+ ...(q["cursor"] === undefined ? {} : { cursor: q["cursor"] }),
212
+ ...(q["sourceId"] === undefined ? {} : { sourceId: q["sourceId"] }),
213
+ ...(q["channel"] === undefined ? {} : { channel: q["channel"] }),
214
+ ...(outcome === undefined ? {} : { outcome: outcome })
215
+ });
216
+ return ok(page);
217
+ }
218
+ case "GET /management/v1/dev/principals":
219
+ noQuery();
220
+ return ok({ items: internals.developmentPrincipals() });
221
+ default:
222
+ break;
223
+ }
224
+ if (request.method !== "POST")
225
+ throw new HttpError(404, "INVALID_REQUEST", "Unknown management route.");
226
+ noQuery();
227
+ const body = await readJsonBody(request);
228
+ switch (route) {
229
+ case "POST /management/v1/source-checks": {
230
+ const sourceId = requireString(shape(body, ["sourceId"])["sourceId"], "sourceId");
231
+ return ok({ steps: await internals.checkSource(sourceId) });
232
+ }
233
+ case "POST /management/v1/config/validate": {
234
+ const { config } = shape(body, ["config"]);
235
+ return ok(validateProjectConfig(config));
236
+ }
237
+ case "POST /management/v1/config/export": {
238
+ const { config } = shape(body, ["config"]);
239
+ const validation = validateProjectConfig(config);
240
+ if (!validation.valid) {
241
+ throw new HttpError(400, "CONFIG_INVALID", "The configuration is invalid and was not exported.", {
242
+ issues: validation.issues.map(issue => ({ path: issue.path, code: issue.code, message: issue.message }))
243
+ });
244
+ }
245
+ return ok({ filename: "streamotter.json", content: canonicalJsonPretty(config), fingerprint: sha256Hex(config) });
246
+ }
247
+ case "POST /management/v1/sources/resume": {
248
+ const sourceId = requireString(shape(body, ["sourceId"])["sourceId"], "sourceId");
249
+ return ok(await internals.resumeSource(sourceId));
250
+ }
251
+ case "POST /management/v1/preview-sessions": {
252
+ const ref = requireString(shape(body, ["fixturePrincipalRef"])["fixturePrincipalRef"], "fixturePrincipalRef");
253
+ return ok(internals.createPreviewSession(ref));
254
+ }
255
+ case "POST /management/v1/dev/fixtures/advance": {
256
+ const fields = shape(body, ["sourceId", "count"]);
257
+ const sourceId = requireString(fields["sourceId"], "sourceId");
258
+ const count = fields["count"];
259
+ if (typeof count !== "number" || !Number.isSafeInteger(count) || count < 1 || count > 100) {
260
+ throw new HttpError(400, "INVALID_REQUEST", "count must be an integer from 1 to 100.");
261
+ }
262
+ return ok({ advanced: await internals.advanceFixture(sourceId, count) });
263
+ }
264
+ case "POST /management/v1/dev/disconnect": {
265
+ const previewSessionId = requireString(shape(body, ["previewSessionId"])["previewSessionId"], "previewSessionId");
266
+ internals.disconnectPreviewSession(previewSessionId);
267
+ return ok(null);
268
+ }
269
+ default:
270
+ throw new HttpError(404, "INVALID_REQUEST", "Unknown management route.");
271
+ }
272
+ }
273
+ async function serveStatic(request, response, pathname) {
274
+ if (workbenchDir === null || (request.method !== "GET" && request.method !== "HEAD")) {
275
+ response.statusCode = 404;
276
+ response.end();
277
+ return;
278
+ }
279
+ let decoded;
280
+ try {
281
+ decoded = decodeURIComponent(pathname);
282
+ }
283
+ catch {
284
+ response.statusCode = 400;
285
+ response.end();
286
+ return;
287
+ }
288
+ const relative = decoded === "/" ? "index.html" : decoded.replace(/^\/+/, "");
289
+ const file = resolvePath(workbenchDir, relative);
290
+ if (!file.startsWith(workbenchDir + sep) || relative.includes("\0")) {
291
+ response.statusCode = 404;
292
+ response.end();
293
+ return;
294
+ }
295
+ let target = file;
296
+ try {
297
+ const info = await stat(target);
298
+ if (info.isDirectory())
299
+ target = join(target, "index.html");
300
+ const real = await realpath(target);
301
+ if (!real.startsWith(workbenchDir + sep))
302
+ throw new Error("outside");
303
+ let content = await readFile(real);
304
+ if (extname(real) === ".html") {
305
+ // Tell the workbench where the gateway listens (not secret; the token is never embedded).
306
+ const address = internals.address();
307
+ const escape = (value) => value.replace(/[&"<>]/g, character => `&#${character.charCodeAt(0)};`);
308
+ const meta = address === null ? "" :
309
+ `<meta name="streamotter-gateway-origin" content="${escape(address.origin)}"><meta name="streamotter-gateway-path" content="${escape(address.path)}">`;
310
+ content = content.toString("utf8").replace("</head>", `${meta}</head>`);
311
+ }
312
+ response.statusCode = 200;
313
+ response.setHeader("Content-Type", CONTENT_TYPES[extname(real)] ?? "application/octet-stream");
314
+ const gatewayOrigin = internals.address()?.origin ?? "";
315
+ const socketOrigin = gatewayOrigin.replace(/^http/, "ws");
316
+ response.setHeader("Content-Security-Policy", [
317
+ "default-src 'self'",
318
+ `connect-src 'self' ${gatewayOrigin} ${socketOrigin}`.trim(),
319
+ "img-src 'self' data:",
320
+ "style-src 'self'",
321
+ "script-src 'self'",
322
+ "frame-ancestors 'none'",
323
+ "base-uri 'none'",
324
+ "form-action 'none'"
325
+ ].join("; "));
326
+ response.end(request.method === "HEAD" ? undefined : content);
327
+ }
328
+ catch {
329
+ response.statusCode = 404;
330
+ response.end();
331
+ }
332
+ }
333
+ await new Promise((resolve, reject) => {
334
+ server.once("error", reject);
335
+ server.listen(options.port ?? DEFAULT_MANAGEMENT_PORT, host, () => {
336
+ server.off("error", reject);
337
+ resolve();
338
+ });
339
+ });
340
+ const address = server.address();
341
+ origin = `http://${host.includes(":") ? `[${host}]` : host}:${address.port}`;
342
+ internals.allowDevelopmentOrigin(origin);
343
+ let closing = null;
344
+ const close = () => {
345
+ closing ??= new Promise(resolve => {
346
+ server.close(() => resolve());
347
+ server.closeAllConnections();
348
+ });
349
+ return closing;
350
+ };
351
+ internals.onStop(close);
352
+ return { origin, token, close };
353
+ }
354
+ //# sourceMappingURL=index.js.map