nebula-ai-gateway 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/README.md ADDED
@@ -0,0 +1,23 @@
1
+ # nebula-ai-gateway
2
+
3
+ The always-on **gateway daemon** for **nebula**. Keeps the agent online when the
4
+ TUI is closed: runs the Telegram listener, routes inline-keyboard approvals, and
5
+ serves a local control plane. Runs locally on your machine (no remote sandbox);
6
+ started with `nebula gateway start`.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ bun add nebula-ai-gateway
12
+ ```
13
+
14
+ Requires [bun](https://bun.sh).
15
+
16
+ ## Use
17
+
18
+ You don't usually run this directly — `nebula gateway start` (from
19
+ [`nebula-treasury`](https://www.npmjs.com/package/nebula-treasury)) spawns it with
20
+ Touch ID + a cached operator session, decrypts the local keystore, and brings the
21
+ listeners online. Documented here for transparency.
22
+
23
+ See the [root README](https://github.com/rstfulzz/nebula#readme).
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ import '../src/entrypoint'
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ import '../src/local-entrypoint'
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "nebula-ai-gateway",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Nebula gateway daemon: the always-on local brain runtime + listeners (Telegram, approvals) for the nebula agent",
6
+ "license": "MIT",
7
+ "homepage": "https://github.com/rstfulzz/nebula",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/rstfulzz/nebula.git",
11
+ "directory": "packages/gateway"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/rstfulzz/nebula/issues"
15
+ },
16
+ "keywords": [
17
+ "nebula",
18
+ "ai",
19
+ "agent",
20
+ "gateway",
21
+ "daemon",
22
+ "mantle"
23
+ ],
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "engines": {
28
+ "bun": ">=1.1"
29
+ },
30
+ "files": [
31
+ "src",
32
+ "!src/**/*.test.ts",
33
+ "bin",
34
+ "README.md"
35
+ ],
36
+ "main": "./src/index.ts",
37
+ "types": "./src/index.ts",
38
+ "bin": {
39
+ "nebula-gateway": "bin/nebula-gateway",
40
+ "nebula-gateway-local": "bin/nebula-gateway-local"
41
+ },
42
+ "scripts": {
43
+ "build": "tsc -b",
44
+ "test": "bun test"
45
+ },
46
+ "dependencies": {
47
+ "nebula-ai-core": "0.1.0",
48
+ "nebula-ai-plugin-onchain": "0.1.0",
49
+ "nebula-ai-plugin-system": "0.1.0",
50
+ "nebula-ai-plugin-telegram": "0.1.0",
51
+ "viem": "^2.21.55",
52
+ "zod": "^3.24.1"
53
+ }
54
+ }
@@ -0,0 +1,91 @@
1
+ import type { EventHub } from './events'
2
+
3
+ export interface ApprovalRequestPayload {
4
+ /** Tool kind (chain.send, chain.swap, shell.run, etc). */
5
+ kind: string
6
+ command?: string
7
+ path?: string
8
+ amount?: string
9
+ recipient?: string
10
+ token?: string
11
+ /** Free-form reason for the human. */
12
+ reason?: string
13
+ }
14
+
15
+ export type ApprovalDecision = 'allow' | 'allow-session' | 'deny' | 'expired'
16
+
17
+ export interface PendingApproval {
18
+ id: string
19
+ payload: ApprovalRequestPayload
20
+ createdAt: number
21
+ expiresAt: number
22
+ resolve: (decision: Exclude<ApprovalDecision, 'expired'>) => void
23
+ }
24
+
25
+ export class ApprovalRelay {
26
+ #pending = new Map<string, PendingApproval>()
27
+ #events: EventHub
28
+ #ttlMs: number
29
+ #idSeq = 0
30
+ #sweepTimer: ReturnType<typeof setInterval> | null = null
31
+
32
+ constructor(events: EventHub, opts: { ttlMs?: number; sweepIntervalMs?: number } = {}) {
33
+ this.#events = events
34
+ this.#ttlMs = opts.ttlMs ?? 5 * 60 * 1000
35
+ const sweepMs = opts.sweepIntervalMs ?? 5_000
36
+ this.#sweepTimer = setInterval(() => this.#sweepExpired(), sweepMs)
37
+ this.#sweepTimer.unref?.()
38
+ }
39
+
40
+ /** Create a pending approval, broadcast event, return a promise resolved by /approval/:id/respond. */
41
+ request(payload: ApprovalRequestPayload): { id: string; promise: Promise<ApprovalDecision> } {
42
+ this.#idSeq += 1
43
+ const id = `apv-${Date.now()}-${this.#idSeq}`
44
+ const createdAt = Date.now()
45
+ const expiresAt = createdAt + this.#ttlMs
46
+
47
+ const promise = new Promise<ApprovalDecision>(resolve => {
48
+ this.#pending.set(id, { id, payload, createdAt, expiresAt, resolve })
49
+ })
50
+ this.#events.publish('approval-needed', { id, payload, expiresAt })
51
+ return { id, promise }
52
+ }
53
+
54
+ /** Operator's signed decision arrived. Returns false if id unknown / already resolved. */
55
+ resolve(id: string, decision: Exclude<ApprovalDecision, 'expired'>): boolean {
56
+ const p = this.#pending.get(id)
57
+ if (!p) return false
58
+ this.#pending.delete(id)
59
+ p.resolve(decision)
60
+ this.#events.publish('approval-resolved', { id, decision })
61
+ return true
62
+ }
63
+
64
+ pendingCount(): number {
65
+ return this.#pending.size
66
+ }
67
+
68
+ has(id: string): boolean {
69
+ return this.#pending.has(id)
70
+ }
71
+
72
+ stop(): void {
73
+ if (this.#sweepTimer) clearInterval(this.#sweepTimer)
74
+ this.#sweepTimer = null
75
+ for (const p of this.#pending.values()) {
76
+ p.resolve('deny')
77
+ }
78
+ this.#pending.clear()
79
+ }
80
+
81
+ #sweepExpired(): void {
82
+ const now = Date.now()
83
+ for (const [id, p] of this.#pending.entries()) {
84
+ if (now >= p.expiresAt) {
85
+ this.#pending.delete(id)
86
+ p.resolve('deny')
87
+ this.#events.publish('approval-expired', { id, expiredAt: now })
88
+ }
89
+ }
90
+ }
91
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,334 @@
1
+ import {
2
+ type Address,
3
+ type Hex,
4
+ encodeAbiParameters,
5
+ isAddressEqual,
6
+ keccak256,
7
+ recoverMessageAddress,
8
+ } from 'viem'
9
+ import type { RuntimeConfig } from './runtime'
10
+
11
+ /** Agent reference carried in a (legacy remote) provision request. */
12
+ interface ProvisionAgentRef {
13
+ contract: Address
14
+ tokenId: string
15
+ }
16
+
17
+ export interface ProvisionEnvelope {
18
+ ephPubkeyHex: Hex
19
+ ivHex: Hex
20
+ tagHex: Hex
21
+ ciphertextHex: Hex
22
+ }
23
+
24
+ export interface ProvisionRequest {
25
+ envelope: ProvisionEnvelope
26
+ /**
27
+ * Optional second ECIES envelope sealing the harness secrets JSON
28
+ * (telegram bot token + allowlist, etc.). Sealed to the same bootstrap
29
+ * pubkey. The operator's signature covers both envelopes so a stolen
30
+ * secrets envelope can't be replayed against a different harness.
31
+ */
32
+ secretsEnvelope?: ProvisionEnvelope
33
+ operatorAddress: Address
34
+ iNFTRef: ProvisionAgentRef
35
+ config: RuntimeConfig
36
+ ts: number
37
+ }
38
+
39
+ function envelopeHash(env: ProvisionEnvelope): Hex {
40
+ return keccak256(
41
+ encodeAbiParameters(
42
+ [
43
+ { type: 'bytes', name: 'eph' },
44
+ { type: 'bytes', name: 'iv' },
45
+ { type: 'bytes', name: 'tag' },
46
+ { type: 'bytes', name: 'ct' },
47
+ ],
48
+ [env.ephPubkeyHex, env.ivHex, env.tagHex, env.ciphertextHex],
49
+ ),
50
+ )
51
+ }
52
+
53
+ function stableStringify(value: unknown): string {
54
+ if (value === null || typeof value !== 'object') return JSON.stringify(value)
55
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`
56
+ // Skip undefined-valued keys to match `JSON.stringify` semantics. Critical
57
+ // because the wire path is `JSON.stringify` → JSON.parse, which silently
58
+ // drops undefined object values. If we hashed them as the literal text
59
+ // `undefined`, the CLI's pre-wire hash and the harness's post-wire hash
60
+ // would diverge for any optional field the caller leaves unset (e.g.
61
+ // `RuntimeConfig.promptAppend`), surfacing as `provision-rejected: sig-mismatch`.
62
+ const v = value as Record<string, unknown>
63
+ const keys = Object.keys(v)
64
+ .filter(k => v[k] !== undefined)
65
+ .sort()
66
+ const props = keys.map(k => `${JSON.stringify(k)}:${stableStringify(v[k])}`)
67
+ return `{${props.join(',')}}`
68
+ }
69
+
70
+ function configHash(config: RuntimeConfig): Hex {
71
+ // Stable JSON via recursive key-sorted stringify; harness + client must agree.
72
+ const stable = stableStringify(config)
73
+ return keccak256(`0x${Buffer.from(stable, 'utf8').toString('hex')}` as Hex)
74
+ }
75
+
76
+ /**
77
+ * Build the deterministic digest the operator signs over. Anchored to the
78
+ * harness bootstrap pubkey + config hash so a stolen envelope cannot be replayed
79
+ * against a different harness or a different runtime config.
80
+ */
81
+ export function provisionMessageHash(req: ProvisionRequest, bootstrapPubkey: Hex): Hex {
82
+ // v0.18+ extends the digest with a secretsEnvelopeHash so a second envelope
83
+ // can ship telegram secrets etc. alongside the agent privkey. Zero-hash
84
+ // sentinel preserves the v0.17 digest when no secrets envelope is sent.
85
+ const secretsHash: Hex = req.secretsEnvelope
86
+ ? envelopeHash(req.secretsEnvelope)
87
+ : ('0x0000000000000000000000000000000000000000000000000000000000000000' as Hex)
88
+ const encoded = encodeAbiParameters(
89
+ [
90
+ { type: 'bytes32', name: 'envelopeHash' },
91
+ { type: 'bytes32', name: 'secretsEnvelopeHash' },
92
+ { type: 'bytes32', name: 'configHash' },
93
+ { type: 'address', name: 'operator' },
94
+ { type: 'address', name: 'inftContract' },
95
+ { type: 'uint256', name: 'tokenId' },
96
+ { type: 'uint64', name: 'ts' },
97
+ { type: 'bytes', name: 'bootstrapPubkey' },
98
+ ],
99
+ [
100
+ envelopeHash(req.envelope),
101
+ secretsHash,
102
+ configHash(req.config),
103
+ req.operatorAddress,
104
+ req.iNFTRef.contract,
105
+ BigInt(req.iNFTRef.tokenId),
106
+ BigInt(req.ts),
107
+ bootstrapPubkey,
108
+ ],
109
+ )
110
+ return keccak256(encoded)
111
+ }
112
+
113
+ export interface VerifyOpts {
114
+ request: ProvisionRequest
115
+ signature: Hex
116
+ bootstrapPubkey: Hex
117
+ expectedOperator: Address
118
+ /** Reject ts older than this (default 5min). */
119
+ maxAgeMs?: number
120
+ /** Reject ts further into the future than this (default 1min for clock skew). */
121
+ maxFutureMs?: number
122
+ now?: number
123
+ }
124
+
125
+ export type VerifyResult = { ok: true } | { ok: false; reason: string }
126
+
127
+ export async function verifyProvisionSig(opts: VerifyOpts): Promise<VerifyResult> {
128
+ const now = opts.now ?? Date.now()
129
+ const maxAge = opts.maxAgeMs ?? 5 * 60 * 1000
130
+ const maxFuture = opts.maxFutureMs ?? 60 * 1000
131
+
132
+ if (!isAddressEqual(opts.request.operatorAddress, opts.expectedOperator)) {
133
+ return { ok: false, reason: 'operator-mismatch' }
134
+ }
135
+ if (opts.request.ts > now + maxFuture) {
136
+ return { ok: false, reason: 'ts-future' }
137
+ }
138
+ if (opts.request.ts < now - maxAge) {
139
+ return { ok: false, reason: 'ts-stale' }
140
+ }
141
+
142
+ const hash = provisionMessageHash(opts.request, opts.bootstrapPubkey)
143
+ let recovered: Address
144
+ try {
145
+ recovered = await recoverMessageAddress({ message: { raw: hash }, signature: opts.signature })
146
+ } catch (e) {
147
+ return { ok: false, reason: `sig-decode: ${(e as Error).message}` }
148
+ }
149
+
150
+ if (!isAddressEqual(recovered, opts.expectedOperator)) {
151
+ return { ok: false, reason: 'sig-mismatch' }
152
+ }
153
+
154
+ return { ok: true }
155
+ }
156
+
157
+ /**
158
+ * Hash the operator signs to authenticate a chat message turn. Anchored to
159
+ * sandboxId so a chat sig cannot be replayed against a different sandbox
160
+ * harness running on the same operator.
161
+ */
162
+ export function chatMessageHash(message: string, ts: number, sandboxId: string): Hex {
163
+ return keccak256(
164
+ encodeAbiParameters(
165
+ [
166
+ { type: 'string', name: 'message' },
167
+ { type: 'uint64', name: 'ts' },
168
+ { type: 'string', name: 'sandboxId' },
169
+ ],
170
+ [message, BigInt(ts), sandboxId],
171
+ ),
172
+ )
173
+ }
174
+
175
+ export interface VerifyChatOpts {
176
+ message: string
177
+ ts: number
178
+ sandboxId: string
179
+ signature: Hex
180
+ expectedOperator: Address
181
+ maxAgeMs?: number
182
+ maxFutureMs?: number
183
+ now?: number
184
+ }
185
+
186
+ export async function verifyChatSig(opts: VerifyChatOpts): Promise<VerifyResult> {
187
+ const now = opts.now ?? Date.now()
188
+ const maxAge = opts.maxAgeMs ?? 5 * 60 * 1000
189
+ const maxFuture = opts.maxFutureMs ?? 60 * 1000
190
+ if (opts.ts > now + maxFuture) return { ok: false, reason: 'ts-future' }
191
+ if (opts.ts < now - maxAge) return { ok: false, reason: 'ts-stale' }
192
+
193
+ const hash = chatMessageHash(opts.message, opts.ts, opts.sandboxId)
194
+ let recovered: Address
195
+ try {
196
+ recovered = await recoverMessageAddress({ message: { raw: hash }, signature: opts.signature })
197
+ } catch (e) {
198
+ return { ok: false, reason: `sig-decode: ${(e as Error).message}` }
199
+ }
200
+ if (!isAddressEqual(recovered, opts.expectedOperator)) {
201
+ return { ok: false, reason: 'sig-mismatch' }
202
+ }
203
+ return { ok: true }
204
+ }
205
+
206
+ /**
207
+ * v0.21.9: hash the operator signs to authenticate an admin tick (e.g.
208
+ * `POST /admin/autotopup/tick`) against the sandbox endpoint. Anchored to
209
+ * `action` + `sandboxId` so a sig for one admin endpoint can't be replayed
210
+ * against another, and the `chat`/`approval` sig spaces stay isolated from
211
+ * admin operations. Pattern mirrors `chatMessageHash` / `approvalResponseHash`.
212
+ *
213
+ * v0.24.4: `AdminAction` is a documentation-only union of actions currently
214
+ * accepted by sandbox endpoints. The hash + verifier accept arbitrary
215
+ * strings (so cross-action replay tests can sign non-existent actions); the
216
+ * allowlist is enforced at the route layer in `server.ts`. Add new admin
217
+ * endpoints here so call-site authors can grep for the canonical name.
218
+ *
219
+ * - 'autotopup-tick' → POST /admin/autotopup/tick
220
+ * - 'profile-key' → POST /admin/profile-key
221
+ * - 'pairing-approve' → POST /admin/pairing/approve
222
+ */
223
+ export type AdminAction = 'autotopup-tick' | 'profile-key' | 'pairing-approve'
224
+
225
+ export function adminTickHash(opts: {
226
+ action: AdminAction | string
227
+ ts: number
228
+ sandboxId: string
229
+ }): Hex {
230
+ return keccak256(
231
+ encodeAbiParameters(
232
+ [
233
+ { type: 'string', name: 'action' },
234
+ { type: 'uint64', name: 'ts' },
235
+ { type: 'string', name: 'sandboxId' },
236
+ ],
237
+ [opts.action, BigInt(opts.ts), opts.sandboxId],
238
+ ),
239
+ )
240
+ }
241
+
242
+ export interface VerifyAdminTickOpts {
243
+ action: AdminAction | string
244
+ ts: number
245
+ sandboxId: string
246
+ signature: Hex
247
+ expectedOperator: Address
248
+ maxAgeMs?: number
249
+ maxFutureMs?: number
250
+ now?: number
251
+ }
252
+
253
+ export async function verifyAdminTickSig(opts: VerifyAdminTickOpts): Promise<VerifyResult> {
254
+ const now = opts.now ?? Date.now()
255
+ const maxAge = opts.maxAgeMs ?? 5 * 60 * 1000
256
+ const maxFuture = opts.maxFutureMs ?? 60 * 1000
257
+ if (opts.ts > now + maxFuture) return { ok: false, reason: 'ts-future' }
258
+ if (opts.ts < now - maxAge) return { ok: false, reason: 'ts-stale' }
259
+
260
+ const hash = adminTickHash({
261
+ action: opts.action,
262
+ ts: opts.ts,
263
+ sandboxId: opts.sandboxId,
264
+ })
265
+ let recovered: Address
266
+ try {
267
+ recovered = await recoverMessageAddress({ message: { raw: hash }, signature: opts.signature })
268
+ } catch (e) {
269
+ return { ok: false, reason: `sig-decode: ${(e as Error).message}` }
270
+ }
271
+ if (!isAddressEqual(recovered, opts.expectedOperator)) {
272
+ return { ok: false, reason: 'sig-mismatch' }
273
+ }
274
+ return { ok: true }
275
+ }
276
+
277
+ /**
278
+ * Hash the operator signs for an approval response.
279
+ */
280
+ export function approvalResponseHash(opts: {
281
+ approvalId: string
282
+ decision: 'allow' | 'allow-session' | 'deny'
283
+ ts: number
284
+ sandboxId: string
285
+ }): Hex {
286
+ return keccak256(
287
+ encodeAbiParameters(
288
+ [
289
+ { type: 'string', name: 'approvalId' },
290
+ { type: 'string', name: 'decision' },
291
+ { type: 'uint64', name: 'ts' },
292
+ { type: 'string', name: 'sandboxId' },
293
+ ],
294
+ [opts.approvalId, opts.decision, BigInt(opts.ts), opts.sandboxId],
295
+ ),
296
+ )
297
+ }
298
+
299
+ export interface VerifyApprovalOpts {
300
+ approvalId: string
301
+ decision: 'allow' | 'allow-session' | 'deny'
302
+ ts: number
303
+ sandboxId: string
304
+ signature: Hex
305
+ expectedOperator: Address
306
+ maxAgeMs?: number
307
+ maxFutureMs?: number
308
+ now?: number
309
+ }
310
+
311
+ export async function verifyApprovalSig(opts: VerifyApprovalOpts): Promise<VerifyResult> {
312
+ const now = opts.now ?? Date.now()
313
+ const maxAge = opts.maxAgeMs ?? 5 * 60 * 1000
314
+ const maxFuture = opts.maxFutureMs ?? 60 * 1000
315
+ if (opts.ts > now + maxFuture) return { ok: false, reason: 'ts-future' }
316
+ if (opts.ts < now - maxAge) return { ok: false, reason: 'ts-stale' }
317
+
318
+ const hash = approvalResponseHash({
319
+ approvalId: opts.approvalId,
320
+ decision: opts.decision,
321
+ ts: opts.ts,
322
+ sandboxId: opts.sandboxId,
323
+ })
324
+ let recovered: Address
325
+ try {
326
+ recovered = await recoverMessageAddress({ message: { raw: hash }, signature: opts.signature })
327
+ } catch (e) {
328
+ return { ok: false, reason: `sig-decode: ${(e as Error).message}` }
329
+ }
330
+ if (!isAddressEqual(recovered, opts.expectedOperator)) {
331
+ return { ok: false, reason: 'sig-mismatch' }
332
+ }
333
+ return { ok: true }
334
+ }