@econ-v1/app-sdk 1.0.0-experimental

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,91 @@
1
+ # @econ-v1/app-sdk
2
+
3
+ > **Status: `1.0.0-experimental`** — API surface is frozen pending validation by at least two first-party packages. Breaking changes between `1.0.0-experimental.*` releases are possible. Pin to an exact version in production.
4
+
5
+ TypeScript / Bun SDK for building **Node-App** plugins for the [Node](https://github.com/econ-v1/node-app-distribution) Lightning-powered marketplace daemon. Apps run as separate Bun processes and talk to the host over a Unix-domain socket using a JSON-newline protocol.
6
+
7
+ ## What it gives you
8
+
9
+ - An abstract `NodeApp` class — extend it, declare metadata, and implement the hooks you need.
10
+ - A declarative `route()` helper — register HTTP handlers with built-in scope checks (defense-in-depth on top of the host's manifest enforcement).
11
+ - `invokeCapability()` — call any capability registered with the host's capability router.
12
+ - `publishEvent()` — publish namespaced domain events to the host event bus.
13
+ - `host.{trace,debug,info,warn,error}` — file-backed structured logging.
14
+
15
+ ## Quick start
16
+
17
+ ```sh
18
+ bun add @econ-v1/app-sdk@1.0.0-experimental
19
+ ```
20
+
21
+ ```typescript
22
+ import { NodeApp, runNodeApp, type AppRequest, type AppResponse } from "@econ-v1/app-sdk";
23
+
24
+ class MyApp extends NodeApp {
25
+ readonly metadata = {
26
+ name: "my-app",
27
+ version: "0.1.0",
28
+ author: "Me",
29
+ description: "Hello-world Node-App",
30
+ capabilities: ["http_handler"],
31
+ };
32
+
33
+ async init() {
34
+ this.route("GET", "/hello", { requiredPermissions: [] }, async () =>
35
+ this.json({ hello: "world" }),
36
+ );
37
+ }
38
+ }
39
+
40
+ runNodeApp(new MyApp());
41
+ ```
42
+
43
+ The host launches the Bun process, supplies a Unix socket via the `NODE_APP_SOCKET` environment variable, and routes proxied HTTP requests, events, and capability calls over the socket.
44
+
45
+ ## Defense-in-depth scope checks
46
+
47
+ ```typescript
48
+ this.route(
49
+ "POST",
50
+ "/admin/**",
51
+ { requiredPermissions: ["payments:write"] },
52
+ async (req) => this.json({ ok: true }),
53
+ );
54
+ ```
55
+
56
+ The host already enforces `endpoint_policies` from your manifest before the request reaches the app — `route()` re-checks `request.caller.granted_permissions` as a second line of defense.
57
+
58
+ ## Calling host capabilities
59
+
60
+ ```typescript
61
+ import { invokeCapability } from "@econ-v1/app-sdk";
62
+
63
+ const response = await invokeCapability({
64
+ id: crypto.randomUUID(),
65
+ capability: "core.storage.get",
66
+ payload: { key: "user_pref" },
67
+ });
68
+ ```
69
+
70
+ ## Publishing events
71
+
72
+ ```typescript
73
+ import { publishEvent } from "@econ-v1/app-sdk";
74
+
75
+ publishEvent("my-app.user_created", { userId: 42 });
76
+ ```
77
+
78
+ Event names **must** be namespaced with the app name (`my-app.*`); the host rejects un-namespaced events.
79
+
80
+ ## ABI compatibility
81
+
82
+ The TypeScript SDK targets **Node Host API v1** over IPC. The wire format is independently versioned but tracks the same semantics as the C ABI used by native (Rust / Go / C / Zig) apps. See `core/host-abi-v1/include/node-host-api-v1.h` for the C surface.
83
+
84
+ ## License
85
+
86
+ Licensed under either of
87
+
88
+ - Apache License, Version 2.0
89
+ - MIT License
90
+
91
+ at your option.
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Node-App SDK for TypeScript/JavaScript
3
+ *
4
+ * Provides the NodeApp abstract class and runNodeApp() entry point
5
+ * for building scripted node-app plugins that communicate with the
6
+ * host backend via Unix Socket IPC using a JSON-newline protocol.
7
+ *
8
+ * Logs are written to files in the NODE_APP_LOG_DIR directory for
9
+ * persistence, while IPC messages are used for real-time streaming.
10
+ *
11
+ * @example
12
+ * ```typescript
13
+ * import { NodeApp, runNodeApp, host } from "@econ-v1/app-sdk";
14
+ *
15
+ * class MyApp extends NodeApp {
16
+ * readonly metadata = {
17
+ * name: "my-app",
18
+ * version: "0.1.0",
19
+ * author: "Me",
20
+ * description: "My app",
21
+ * capabilities: ["http_handler"],
22
+ * };
23
+ *
24
+ * async handleRequest(request: AppRequest): Promise<AppResponse> {
25
+ * return { status: 200, headers: {}, body: { hello: "world" } };
26
+ * }
27
+ * }
28
+ *
29
+ * runNodeApp(new MyApp());
30
+ * ```
31
+ */
32
+ export interface AppMetadata {
33
+ name: string;
34
+ version: string;
35
+ author: string;
36
+ description: string;
37
+ capabilities: string[];
38
+ }
39
+ /**
40
+ * Caller identity set by the host proxy after validating the scoped JWT.
41
+ * Only present on requests that arrived through the proxy route.
42
+ * Apps should trust this struct — it is injected by the host over the Unix IPC socket,
43
+ * not derived from HTTP headers (which are stripped by the proxy).
44
+ */
45
+ export interface CallerContext {
46
+ /** App name whose scoped JWT was used — always equals this app's name */
47
+ app_name: string;
48
+ /** Permissions granted at consent time and embedded in the scoped JWT */
49
+ granted_permissions: string[];
50
+ }
51
+ export interface AppRequest {
52
+ id: string;
53
+ method: string;
54
+ path: string;
55
+ /** HTTP headers forwarded from the client (x-node-* headers are stripped by the proxy) */
56
+ headers: Record<string, string>;
57
+ body: unknown;
58
+ /**
59
+ * Caller identity injected by the host proxy after JWT validation.
60
+ * null for direct/internal requests (e.g. capability invocations from the host).
61
+ * Use this for auth context — do NOT read x-node-* headers.
62
+ */
63
+ caller: CallerContext | null;
64
+ }
65
+ export interface AppResponse {
66
+ status: number;
67
+ headers: Record<string, string>;
68
+ body: unknown;
69
+ }
70
+ export interface AppEvent {
71
+ name: string;
72
+ data: unknown;
73
+ }
74
+ export interface CapabilityRequest {
75
+ id: string;
76
+ capability: string;
77
+ payload: unknown;
78
+ }
79
+ export interface CapabilityResponse {
80
+ id: string;
81
+ success: boolean;
82
+ payload: unknown;
83
+ }
84
+ export interface CapabilityExample {
85
+ label: string;
86
+ request: unknown;
87
+ }
88
+ export interface ProvidedCapability {
89
+ name: string;
90
+ description: string;
91
+ request_schema?: unknown;
92
+ response_schema?: unknown;
93
+ priority?: number;
94
+ examples?: CapabilityExample[];
95
+ }
96
+ /**
97
+ * Host interaction helper for logging from within app code.
98
+ * Logs are written to files for persistence and sent via IPC for real-time streaming.
99
+ */
100
+ export declare const host: {
101
+ trace: (message: string) => void;
102
+ debug: (message: string) => void;
103
+ info: (message: string) => void;
104
+ warn: (message: string) => void;
105
+ error: (message: string) => void;
106
+ };
107
+ /**
108
+ * Invoke a capability on the host via the capability router.
109
+ * Returns a promise that resolves with the capability response.
110
+ *
111
+ * @param request - The capability request to invoke
112
+ * @param timeoutMs - Optional timeout in milliseconds (default: 30000)
113
+ */
114
+ export declare function invokeCapability(request: CapabilityRequest, timeoutMs?: number): Promise<CapabilityResponse>;
115
+ /**
116
+ * Publish an event to the host event bus.
117
+ *
118
+ * The event_name MUST be namespaced with the app name prefix
119
+ * (e.g., "my-app.status.updated"). The host validates the namespace
120
+ * and rejects events that do not start with "{app_name}.".
121
+ *
122
+ * This is a fire-and-forget operation — no response is returned.
123
+ */
124
+ export declare function publishEvent(eventName: string, data: unknown): void;
125
+ interface RoutePolicy {
126
+ /** All listed permissions must be present in request.caller.granted_permissions */
127
+ requiredPermissions?: string[];
128
+ }
129
+ type RouteHandler = (req: AppRequest) => Promise<AppResponse>;
130
+ /**
131
+ * Abstract base class for TypeScript/JavaScript node-apps.
132
+ *
133
+ * Subclass this and implement handleRequest/handleEvent as needed.
134
+ * Prefer using `route()` for HTTP handler registration — it provides
135
+ * declarative scope enforcement as a defense-in-depth layer on top of
136
+ * the host-side manifest endpoint_policies check.
137
+ */
138
+ export declare abstract class NodeApp {
139
+ abstract readonly metadata: AppMetadata;
140
+ private readonly _routes;
141
+ /**
142
+ * Register a declarative route handler with an optional scope policy.
143
+ *
144
+ * The host already enforces `endpoint_policies` from the manifest before the
145
+ * request reaches the app. This method provides a defense-in-depth layer:
146
+ * the SDK re-checks `request.caller.granted_permissions` before dispatching.
147
+ *
148
+ * @param method HTTP method to match ("GET", "POST", "*" for any)
149
+ * @param path Path glob relative to app root ("/data", "/admin/**")
150
+ * @param policy Optional scope requirements
151
+ * @param handler Async handler called when route + policy match
152
+ *
153
+ * @example
154
+ * ```typescript
155
+ * this.route("GET", "/data", { requiredPermissions: ["payments:read"] },
156
+ * async (req) => this.json(await fetchData()));
157
+ * ```
158
+ */
159
+ protected route(method: string, path: string, policy: RoutePolicy, handler: RouteHandler): void;
160
+ /**
161
+ * Initialize the app with configuration from the host.
162
+ * Override for custom initialization logic.
163
+ * If using `route()`, register routes here.
164
+ */
165
+ init(_config: Record<string, unknown>): Promise<void>;
166
+ /**
167
+ * Shut down the app gracefully.
168
+ * Override for custom cleanup logic.
169
+ */
170
+ shutdown(): Promise<void>;
171
+ /**
172
+ * Handle an incoming HTTP request proxied from the host.
173
+ *
174
+ * If routes are registered via `route()`, the base implementation dispatches
175
+ * automatically (with scope enforcement). Override this only for fully custom
176
+ * routing logic — the override bypasses the SDK-level scope check.
177
+ */
178
+ handleRequest(request: AppRequest): Promise<AppResponse>;
179
+ /** Internal route dispatcher — checks scope then calls handler */
180
+ private _dispatchRoute;
181
+ /**
182
+ * Handle a domain event forwarded from the host.
183
+ * Override if the app declares "event_listener" capability.
184
+ */
185
+ handleEvent(_event: AppEvent): Promise<void>;
186
+ /**
187
+ * Return the list of service capabilities this app provides.
188
+ * Override to declare capabilities for the capability registry.
189
+ * Default returns an empty list (no capabilities provided).
190
+ */
191
+ providedCapabilities(): ProvidedCapability[];
192
+ /**
193
+ * Handle a capability invocation from another app via the capability router.
194
+ * Override to implement capability handling logic.
195
+ * Default returns an error response.
196
+ */
197
+ handleCapability(_request: CapabilityRequest): Promise<CapabilityResponse>;
198
+ protected json(body: unknown, status?: number): AppResponse;
199
+ protected error(message: string, status?: number): AppResponse;
200
+ protected notFound(): AppResponse;
201
+ }
202
+ /**
203
+ * Connect the app to the host backend via Unix Socket IPC.
204
+ *
205
+ * The socket path is provided via the `NODE_APP_SOCKET` environment variable.
206
+ * The host creates the socket and waits for the app to connect.
207
+ *
208
+ * Protocol:
209
+ * 1. App connects to Unix socket
210
+ * 2. Host sends init message with config
211
+ * 3. App sends ready message with metadata
212
+ * 4. Host sends request/event messages, app responds
213
+ * 5. Host sends shutdown, app cleans up and exits
214
+ */
215
+ export declare function runNodeApp(app: NodeApp): void;
216
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ // @bun
2
+ import{createConnection as G}from"net";import{appendFileSync as N,mkdirSync as E,existsSync as S}from"fs";import{join as x}from"path";var Y=process.env.NODE_APP_LOG_DIR,C=Y?x(Y,"app.log"):null,$=!1;function F(){if($||!Y)return;try{if(!S(Y))E(Y,{recursive:!0});$=!0}catch{}}function w(z,B){if(!C)return;if(F(),!$)return;let A=`${new Date().toISOString()} [${z.toUpperCase().padEnd(5)}] ${B}
3
+ `;try{N(C,A)}catch{}}var R=null,Z=new Map,Q={trace:(z)=>X("trace",z),debug:(z)=>X("debug",z),info:(z)=>X("info",z),warn:(z)=>X("warn",z),error:(z)=>X("error",z)};function X(z,B){if(w(z,B),R)R({type:"log",level:z,message:B});else console[z==="trace"?"debug":z](B)}function T(z,B){if(!R)return Promise.reject(Error("Not connected to host"));let J=B??30000;return new Promise((A,O)=>{Z.set(z.id,{resolve:A,reject:O}),R({type:"invoke_capability",id:z.id,request:z}),setTimeout(()=>{if(Z.has(z.id))Z.delete(z.id),O(Error(`Capability invocation timed out after ${J}ms: ${z.capability}`))},J)})}function D(z,B){if(!R)throw Error("Not connected to host");R({type:"publish_event",event_name:z,data:B})}function M(z,B){let J=z.split("/"),A=B.split("/"),O=0,K=0;while(O<J.length){if(J[O]==="**")return!0;if(K>=A.length)return!1;if(J[O]!=="*"&&J[O]!==A[K])return!1;O++,K++}return K===A.length}class j{_routes=[];route(z,B,J,A){this._routes.push({method:z,path:B,policy:J,handler:A})}async init(z){}async shutdown(){}async handleRequest(z){if(this._routes.length>0)return this._dispatchRoute(z);return{status:501,headers:{},body:{error:"Not implemented"}}}async _dispatchRoute(z){let B=z.method.toUpperCase(),J=z.path;for(let A of this._routes){if(!(A.method==="*"||A.method.toUpperCase()===B))continue;if(!M(A.path,J))continue;let K=A.policy.requiredPermissions??[],V=z.caller?.granted_permissions??[],U=K.filter((W)=>!V.includes(W));if(U.length>0)return Q.warn(`SDK scope denied: [${U.join(", ")}] missing for ${B} ${J}`),{status:403,headers:{},body:{error:`Missing permissions: ${U.join(", ")}`}};return A.handler(z)}return this.notFound()}async handleEvent(z){}providedCapabilities(){return[]}async handleCapability(z){return{id:z.id,success:!1,payload:{error:"Capability handling not implemented"}}}json(z,B=200){return{status:B,headers:{"Content-Type":"application/json"},body:z}}error(z,B=500){return{status:B,headers:{"Content-Type":"application/json"},body:{error:z}}}notFound(){return this.error("Not found",404)}}function k(z){let B=process.env.NODE_APP_SOCKET;if(!B)console.error("[node-app-sdk] NODE_APP_SOCKET environment variable not set"),process.exit(1);let J="",A=G(B,()=>{Q.debug(`Connected to host via ${B}`)}),O=(K)=>{A.write(JSON.stringify(K)+`
4
+ `)};R=O,A.on("data",async(K)=>{J+=K.toString("utf-8");let V;while((V=J.indexOf(`
5
+ `))!==-1){let U=J.slice(0,V).trim();if(J=J.slice(V+1),!U)continue;try{let W=JSON.parse(U);await y(z,W,O)}catch(W){Q.error(`Failed to parse IPC message: ${W}`)}}}),A.on("error",(K)=>{console.error(`[node-app-sdk] Socket error: ${K.message}`),process.exit(1)}),A.on("close",()=>{Q.debug("Socket closed, shutting down"),process.exit(0)}),process.on("SIGTERM",async()=>{await z.shutdown(),A.end(),process.exit(0)}),process.on("SIGINT",async()=>{await z.shutdown(),A.end(),process.exit(0)})}async function y(z,B,J){switch(B.type){case"init":{let A=B.config;await z.init(A),J({type:"ready",metadata:z.metadata});break}case"request":{let{id:A,request:O}=B;try{let K=await z.handleRequest(O);J({type:"response",id:A,response:K})}catch(K){J({type:"response",id:A,response:{status:500,headers:{},body:{error:`Internal app error: ${K}`}}})}break}case"event":{let{event:A}=B;try{await z.handleEvent(A)}catch(O){Q.error(`Event handler error: ${O}`)}break}case"capability_request":{let{id:A,request:O}=B;try{let K=await z.handleCapability(O);J({type:"capability_response",id:A,response:K})}catch(K){J({type:"capability_response",id:A,response:{id:O.id,success:!1,payload:{error:`Capability handler error: ${K}`}}})}break}case"invoke_capability_result":{let{id:A,response:O}=B,K=Z.get(A);if(K)Z.delete(A),K.resolve(O);break}case"shutdown":{Q.info("Received shutdown signal from host"),await z.shutdown(),process.exit(0);break}default:Q.warn(`Unknown IPC message type: ${B.type}`)}}export{k as runNodeApp,D as publishEvent,T as invokeCapability,Q as host,j as NodeApp};
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@econ-v1/app-sdk",
3
+ "version": "1.0.0-experimental",
4
+ "description": "TypeScript SDK for building Node mini-app plugins (Bun runtime) on the Lightning-powered marketplace daemon",
5
+ "license": "MIT OR Apache-2.0",
6
+ "author": "Node contributors",
7
+ "homepage": "https://github.com/econ-v1/node",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/econ-v1/node.git",
11
+ "directory": "sdk/typescript"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/econ-v1/node/issues"
15
+ },
16
+ "keywords": [
17
+ "node-app",
18
+ "plugin",
19
+ "ipc",
20
+ "lightning",
21
+ "bun",
22
+ "sdk"
23
+ ],
24
+ "type": "module",
25
+ "main": "dist/index.js",
26
+ "types": "dist/index.d.ts",
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "default": "./dist/index.js"
31
+ }
32
+ },
33
+ "files": [
34
+ "dist",
35
+ "README.md"
36
+ ],
37
+ "engines": {
38
+ "node": ">=18",
39
+ "bun": ">=1.0"
40
+ },
41
+ "scripts": {
42
+ "build": "rm -rf dist && bun build src/index.ts --outdir dist --target=bun --minify && tsc --emitDeclarationOnly --declaration --outDir dist",
43
+ "prepublishOnly": "npm run build",
44
+ "typecheck": "tsc --noEmit",
45
+ "pack:dry": "npm pack --dry-run"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "devDependencies": {
51
+ "typescript": "^5.0.0",
52
+ "bun-types": "latest"
53
+ }
54
+ }