diff --git a/CHANGELOG.md b/CHANGELOG.md index 143439a4f..7e9f4c7db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Compose authenticated installed API and request roots (#617). - Add native API container provider and exact service definition (#617). ### Fixed diff --git a/README.md b/README.md index 62083d302..f566d144c 100644 --- a/README.md +++ b/README.md @@ -12,3 +12,9 @@ Development: Bun 1.4.4, Node 24.19.0, TypeScript 5.9.3, native Cordis 4.0.0-rc.1 All capabilities that can be plugins, including container services, are native Cordis class plugins with typed swappable contracts. No parallel DI graph. Keep independent repository/public package boundaries, exact archive pins and original licenses. Migration PRs target cordis with Part of issue references. All issues remain open until authorized release; installer/Agent/generalized recovery are later tasks. Delivery is split into two reviewable changes under #617: the native container provider/service definition baseline, then the API server root and authenticated installed interfaces. Each targets cordis and carries its own checks. The server follow-up consumes the first slice and the exact Identity, Admin and Deployment archives. This baseline does not claim an implemented server. + +## Server follow-up contract + +The next slice adds ApiApplication.start/close with the actual native api.host, platform.invoker, api.routes and api.http providers. Identity supplies OIDC/principal/session providers; Admin supplies its route/browser contribution. Each HTTP request receives a native isolated identity.principal child; ordinary headers/body fields never become trusted authority. The public DeploymentWorkerClient is the only host transport and API has no Docker socket/controller. GET /v1/platform, /v1/platform/status and /v1/platform/applications/{id} require the configured session and platform.read. Other declared seven-operation surfaces report unavailable explicitly in this slice. GET /health/ready reports component/context readiness; installed overall readiness comes from the worker. The owner build generates the image context from exact public runtime packages, without dependency execution or network builds. + +An authenticated request for an unknown well-formed application ID returns HTTP 404 with the safe `APPLICATION_NOT_FOUND` code. Missing authority remains 401/403 and unavailable worker/runtime failures remain 503; responses never expose local journal paths or internal exception details. diff --git a/bun.lock b/bun.lock index 48c8a55a9..72de1072f 100644 --- a/bun.lock +++ b/bun.lock @@ -6,22 +6,37 @@ "name": "@treeseed-cordis/api", "dependencies": { "cordis": "4.0.0-rc.12", + "jsonc-parser": "3.3.1", }, "devDependencies": { + "@treeseed-cordis/admin": "file:vendor/admin.tgz", + "@treeseed-cordis/deployment": "file:vendor/deployment.tgz", + "@treeseed-cordis/identity": "file:vendor/identity.tgz", "@treeseed-cordis/sdk": "file:vendor/sdk.tgz", "@types/bun": "1.4.4", "@types/node": "24.10.1", "typescript": "5.9.3", }, "peerDependencies": { + "@treeseed-cordis/admin": "0.0.0-cordis.1", + "@treeseed-cordis/deployment": "0.0.0-cordis.4", + "@treeseed-cordis/identity": "0.0.0-cordis.1", "@treeseed-cordis/sdk": "0.0.0-cordis.4", }, }, }, "packages": { + "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@treeseed-cordis/sdk": ["@treeseed-cordis/sdk@vendor/sdk.tgz", { "dependencies": { "cordis": "4.0.0-rc.12", "jsonc-parser": "3.3.1", "semver": "7.8.5", "zod": "4.6.7" } }, "sha512-2EoB8+NYfkLyjvEqRW/HhV8xCbokGCUJkqWfBid7kQp5/R2CowBj6EWnVv1wtKFZVoPI3qiLYMWWYAp1qxzPcA=="], + "@treeseed-cordis/admin": ["@treeseed-cordis/admin@vendor/admin.tgz", { "dependencies": { "cordis": "4.0.0-rc.12" }, "peerDependencies": { "@treeseed-cordis/identity": "0.0.0-cordis.1", "@treeseed-cordis/sdk": "0.0.0-cordis.4" } }, "sha512-9w4rdsP253+BaELE9HDzRr272N6ZFBZ7nBzyreqRuyKqzx8bQyVC3+XNeYnk1JvzRP0PcWzl8523bwGKZqNmsA=="], + + "@treeseed-cordis/deployment": ["@treeseed-cordis/deployment@vendor/deployment.tgz", { "dependencies": { "cordis": "4.0.0-rc.12", "jsonc-parser": "3.3.1", "semver": "7.8.5", "tar": "7.5.22" }, "peerDependencies": { "@treeseed-cordis/sdk": "0.0.0-cordis.4" } }, "sha512-RmtkuIPbRW8sTdeoGB+n11xxmgetyLeai7geTTz5dNVzgvH0OZyxwXk3DA3VZN/GIhkqz5+lRZX4jM/2vCJ0Cg=="], + + "@treeseed-cordis/identity": ["@treeseed-cordis/identity@vendor/identity.tgz", { "dependencies": { "cordis": "4.0.0-rc.12", "openid-client": "6.8.8", "postgres": "3.4.9" }, "peerDependencies": { "@treeseed-cordis/sdk": "0.0.0-cordis.4" } }, "sha512-YwKbVwvDBPeyHDo21Jx1yEjV+gJVmWL2xgoFWuyz6Uj8PC/pa9e6wUJNFVbvP5YqrmoX4RPFH85F0rc2cgt4fQ=="], + + "@treeseed-cordis/sdk": ["@treeseed-cordis/sdk@vendor/sdk.tgz", { "dependencies": { "cordis": "4.0.0-rc.12", "jsonc-parser": "3.3.1", "semver": "7.8.5", "zod": "4.6.7" } }, "sha512-WSi3FYhgnA7TK9RGVn7vp+IgngCGlaCrv5lb1AJ8gGCGHW42/u40HIviMFINQDLxGyUILzMD7q19KnP+1VIDmQ=="], "@types/bun": ["@types/bun@1.4.4", "", { "dependencies": { "bun-types": "1.4.4" } }, "sha512-9wkRDXUXK124pEnAutE6n/l18j+i9GC5IAHK8TmX/2nlySETmky0OU6yL5U8pW1AoKh++AN9auDCr647BfurjQ=="], @@ -29,18 +44,36 @@ "bun-types": ["bun-types@1.4.4", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-yjrJh5oGX7mMPKGd19YXyIjmEuj4G4zNC30bs7PM9Az7NQ3ndF3j6hbeRdvJTRQEhJ/FYQwW0a2moweEi2hMxQ=="], + "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + "cordis": ["cordis@4.0.0-rc.12", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "cosmokit": "^1.8.1" }, "peerDependencies": { "@cordisjs/plugin-include": "^1.2.0", "@cordisjs/plugin-loader": "^1.0.0-rc.9" }, "optionalPeers": ["@cordisjs/plugin-include", "@cordisjs/plugin-loader"], "bin": "bin.js" }, "sha512-/ZtRivYxr5sffTULPewHuX5kEy5sWi8oADHSpnt0KjAd8uKRIj+kv4fh3lxtutIfVlgwJVFVXWOQp/gYa+kIOQ=="], "cosmokit": ["cosmokit@1.8.1", "", {}, "sha512-PDBv4l90xZKrUsZ0vtoycgZpO/j4iFsqJXrAxsyBDsnQRI7ZMJXIjgDJsKNjd5L8jnVnnlrDCdhkFbTncgCVjQ=="], + "jose": ["jose@6.2.13", "", {}, "sha512-7TIg10ZE1x82YediN+lcrPNvGMKge9yFHQD+Qho/b8y2adfugU3AsvvHfxV0fzyFllT6l9xCjf4RWY9g/Z9PJQ=="], + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], + + "oauth4webapi": ["oauth4webapi@3.8.8", "", {}, "sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng=="], + + "openid-client": ["openid-client@6.8.8", "", { "dependencies": { "jose": "^6.2.12", "oauth4webapi": "^3.8.8" } }, "sha512-ZsucJA5Ad04Uv7YN4ql+s4GXNmb9uAYQwyJTsJx7CH/MX3JZioLE2pVKi1SC+YrbSLA4Px3Gi30dMjgOZtb6pA=="], + + "postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="], + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "tar": ["tar@7.5.22", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + "zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="], } } diff --git a/package.json b/package.json index 9bed09268..b01565ccc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@treeseed-cordis/api", - "version": "0.0.0-cordis.1", + "version": "0.0.0-cordis.2", "private": true, "type": "module", "license": "AGPL-3.0-only", @@ -10,8 +10,8 @@ }, "exports": { ".": { - "types": "./dist/src/container.d.ts", - "default": "./dist/src/container.js" + "types": "./dist/src/server.d.ts", + "default": "./dist/src/server.js" }, "./container": { "types": "./dist/src/container.d.ts", @@ -25,29 +25,51 @@ ], "scripts": { "check": "bun node_modules/@treeseed-cordis/sdk/dist/src/source-policy.js check", - "build": "bun node_modules/@treeseed-cordis/sdk/dist/src/source-policy.js build && bun dist/src/container.js --metadata", + "build": "bun node_modules/@treeseed-cordis/sdk/dist/src/source-policy.js build && bun dist/src/container.js --metadata && bun dist/src/server.js --metadata", "test:unit": "bun test ./tests --test-name-pattern '^unit:'", "test:integration": "bun test ./tests --test-name-pattern '^integration:'", "test:smoke": "bun test ./tests --test-name-pattern '^smoke:'", "verify": "bun run check && bun run build && bun run test:unit && bun run test:integration && bun run test:smoke" }, "peerDependencies": { - "@treeseed-cordis/sdk": "0.0.0-cordis.4" + "@treeseed-cordis/sdk": "0.0.0-cordis.4", + "@treeseed-cordis/identity": "0.0.0-cordis.1", + "@treeseed-cordis/admin": "0.0.0-cordis.1", + "@treeseed-cordis/deployment": "0.0.0-cordis.4" }, "devDependencies": { "@treeseed-cordis/sdk": "file:vendor/sdk.tgz", "typescript": "5.9.3", "@types/bun": "1.4.4", - "@types/node": "24.10.1" + "@types/node": "24.10.1", + "@treeseed-cordis/identity": "file:vendor/identity.tgz", + "@treeseed-cordis/admin": "file:vendor/admin.tgz", + "@treeseed-cordis/deployment": "file:vendor/deployment.tgz" }, "dependencies": { - "cordis": "4.0.0-rc.12" + "cordis": "4.0.0-rc.12", + "jsonc-parser": "3.3.1" }, "cordisCandidate": { "sdk": { "repository": "https://github.com/treeseed-ai/sdk.git", - "commit": "c03a2b0eab89786439eddea3cff8161e85676b3e", - "sha256": "d6d0f2f4379d2d95403a226d20899ad22a78a11ccdbb4106116ef0a925295d1b" + "commit": "f44ede394a58814b5d6e73146ba9ed7246b4f2ca", + "sha256": "16d3d7f699c55795b97972ca538bfeedeafdc6bfc4c32ed01708871ea02fe75c" + }, + "identity": { + "repository": "https://github.com/treeseed-ai/identity.git", + "commit": "906d64e1dc9219786444f5f793968592bb8abd96", + "sha256": "dd3131499b01b8ad8d4c8aa78d0cb99de69da86c4fbad1da0670439c3d8bdddc" + }, + "admin": { + "repository": "https://github.com/treeseed-ai/admin.git", + "commit": "5b30c2af69c5d273936cfddd480e7f5891c75db9", + "sha256": "a7750c6f38187e44b2823eb778f66b73c4596983226dd80c2204208f96848e28" + }, + "deployment": { + "repository": "https://github.com/treeseed-ai/deployment.git", + "commit": "6a861aa81f3786b74752191b461d1bcddf99fce5", + "sha256": "7015e2447eb4d98f9979e8f10a3b33480c8ca80818384e2cbf939eba550ab65d" } }, "cordis": { @@ -135,5 +157,61 @@ "nativeComposition": true } } - } + }, + "cordisProviders": [ + { + "id": "private-worker", + "pluginId": "api", + "capability": "api.host", + "contractVersion": "0.3.0-cordis.1", + "target": "api", + "provide": [ + "api.host" + ], + "inject": [] + }, + { + "id": "platform-operations", + "pluginId": "api", + "capability": "platform.invoker", + "contractVersion": "0.3.0-cordis.1", + "target": "api", + "provide": [ + "platform.invoker" + ], + "inject": [ + "api.host", + "cordis.composition" + ] + }, + { + "id": "installed-routes", + "pluginId": "api", + "capability": "api.routes", + "contractVersion": "0.3.0-cordis.1", + "target": "api", + "provide": [ + "api.routes" + ], + "inject": [ + "cordis.composition", + "identity.sessions", + "admin.routes", + "platform.invoker" + ] + }, + { + "id": "node-http", + "pluginId": "api", + "capability": "api.http", + "contractVersion": "0.3.0-cordis.1", + "target": "api", + "provide": [ + "api.http" + ], + "inject": [ + "api.routes" + ] + } + ] } diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 000000000..d6e26f320 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,111 @@ +import {createServer,type Server,type IncomingMessage,type ServerResponse} from 'node:http'; +import {openSync,closeSync,fstatSync,lstatSync,readFileSync,writeFileSync,mkdirSync,constants} from 'node:fs'; +import {resolve,dirname,join} from 'node:path'; +import {createHash} from 'node:crypto'; +import {parse,visit,type ParseError} from 'jsonc-parser'; +import {Service,FiberState} from 'cordis'; +import {CapabilityToken,PlanningBlockedError,ProviderSchemas,PlatformSchemas,PlatformOperations,ContractProjection,CanonicalJson,InstalledSchemas,InstalledTokens,type ProviderDeclaration,type ProviderArtifact,type PlatformInvoker,type ServerConfiguration,type TrustedPrincipal} from '@treeseed-cordis/sdk'; +import {Context,CordisComposition,CordisServiceLocator,type CompositionHandle,type ProviderRegistration,type ProviderLease} from '@treeseed-cordis/sdk/runtime'; +import {DeploymentWorkerClient,PackageArchiveMaterializer} from '@treeseed-cordis/deployment'; +import {IdentityComposition,IdentitySessionsToken,type IdentitySessions} from '@treeseed-cordis/identity'; +import {AdminComposition,AdminToken,AdminRoutes,type AdminContribution} from '@treeseed-cordis/admin'; +import {NodeApiContainerPlugin} from './container.js'; +export {NodeApiContainerPlugin,NodeApiContainerPlugin as Provider} from './container.js'; +class JsonKeys {private readonly stack:Set[]=[];duplicate=false;begin():void{this.stack.push(new Set());}property(value:string):void{const keys=this.stack[this.stack.length-1]!;if(keys.has(value)||value==='__proto__')this.duplicate=true;keys.add(value);}end():void{this.stack.pop();}} +class ApiJson {static parse(bytes:Uint8Array):unknown{if(bytes.byteLength>1024*1024)throw new Error('Input is too large');const text=new TextDecoder('utf-8',{fatal:true}).decode(bytes),keys=new JsonKeys(),errors:ParseError[]=[];visit(text,{onObjectBegin:keys.begin.bind(keys),onObjectProperty:keys.property.bind(keys),onObjectEnd:keys.end.bind(keys)},{allowTrailingComma:false,disallowComments:true});const value:unknown=parse(text,errors,{allowTrailingComma:false,disallowComments:true});if(keys.duplicate||errors.length)throw new Error('Invalid JSON');return value;}} +export interface ApiHost {status():Promise>;application(id:string):Promise>;} +export interface ApiRouter {handle(request:Request):Promise;} +export interface ApiListener {address():string;} +class Adapter{constructor(private readonly methods:string[]){}parse(value:unknown):T{if(!value||typeof value!=='object')throw new Error('API adapter unavailable');for(const method of this.methods)if(typeof Reflect.get(value,method)!=='function')throw new Error('API adapter unavailable');return value as T;}} +export const ApiHostToken=new CapabilityToken('api.host',ProviderSchemas.version,new Adapter(['status','application'])); +export const ApiRouterToken=new CapabilityToken('api.routes',ProviderSchemas.version,new Adapter(['handle'])); +export const ApiHttpToken=new CapabilityToken('api.http',ProviderSchemas.version,new Adapter(['address'])); +class Declaration {static define(id:string,capability:string,inject:string[]):ProviderDeclaration{return ProviderSchemas.declaration.parse({id,pluginId:'api',capability,contractVersion:ProviderSchemas.version,target:'api',provide:[capability],inject});}} +class HostAdapter implements ApiHost { + private readonly client:DeploymentWorkerClient; + constructor(private readonly settings:ServerConfiguration){this.client=new DeploymentWorkerClient({socket:settings.workerSocket});} + async initialize():Promise{const status=await this.status();if(status.installationId!==this.settings.installationId)throw new Error('Worker scope mismatch');} + status():Promise>{return this.client.status();} + application(id:string):Promise>{return this.client.application(id);} +} +class HostPlugin { + static readonly declaration=Declaration.define('private-worker',ApiHostToken.id,[]);static readonly provide=this.declaration.provide;static readonly inject=this.declaration.inject;static readonly Config=InstalledSchemas.serverConfiguration; + private readonly host:HostAdapter; + constructor(private readonly context:Context,settings:ServerConfiguration){this.host=new HostAdapter(settings);} + async [Service.init]():Promise{await this.host.initialize();this.context.provide(ApiHostToken.id,this.host);} +} +class OperationUnavailable extends Error {} +class AuthorityDenied extends Error {} +class InputRejected extends Error {} +class Invoker implements PlatformInvoker { + constructor(private readonly host:ApiHost,private readonly context:Context,private readonly settings:ServerConfiguration){} + async invoke(operation:string,input:unknown,principal:TrustedPrincipal):Promise{principal=InstalledSchemas.principal.parse(principal);let binding:typeof PlatformOperations.all[number]|null=null;for(const candidate of PlatformOperations.all)if(candidate.definition.id===operation)binding=candidate;if(!binding)throw new OperationUnavailable();if(principal.issuer!==this.settings.issuer||!principal.scopes.includes(binding.definition.scope))throw new AuthorityDenied();let value:unknown;try{value=binding.parseInput(input);}catch{throw new InputRejected();}if(operation==='platform.status')return PlatformSchemas.status.parse(await this.host.status());if(operation==='platform.applications.get')return PlatformSchemas.application.parse(await this.host.application((value as {id:string}).id));if(operation==='platform.describe'){const projection=ContractProjection.generate() as {contractVersion:string;digest:string};const handle=Reflect.get(this.context,'cordis.composition') as CompositionHandle;handle.healthy();const plugins=[];for(const token of [ApiHostToken,InstalledTokens.invoker,ApiRouterToken,ApiHttpToken,IdentitySessionsToken,AdminToken] as CapabilityToken[])for(const item of handle.describe(token))plugins.push({id:item.id,ready:item.ready&&item.selected});return PlatformSchemas.describe.parse({contractVersion:projection.contractVersion,platformVersion:'0.0.0-cordis.2',schemaDigest:projection.digest,plugins,operations:[PlatformOperations.describe,PlatformOperations.status,PlatformOperations.get].map(Invoker.id)});}throw new OperationUnavailable();} + private static id(operation:typeof PlatformOperations.all[number]):string{return operation.definition.id;} +} +class InvokerPlugin { + static readonly declaration=Declaration.define('platform-operations',InstalledTokens.invoker.id,[ApiHostToken.id,'cordis.composition']);static readonly provide=this.declaration.provide;static readonly inject=this.declaration.inject;static readonly Config=InstalledSchemas.serverConfiguration; + constructor(context:Context,settings:ServerConfiguration){context.provide(InstalledTokens.invoker.id,new Invoker(ApiHostToken.parse(Reflect.get(context,ApiHostToken.id)),context,settings));} +} +class RequestPrincipalPlugin {static readonly provide=['identity.principal'];static readonly Config=InstalledSchemas.principal.nullable();constructor(context:Context,principal:TrustedPrincipal|null){context.provide('identity.principal',principal);}} +class RequestActionPlugin {static readonly inject=['identity.principal',InstalledTokens.invoker.id,AdminToken.id,IdentitySessionsToken.id];constructor(private readonly context:Context,private readonly work:RequestWork){}async [Service.init]():Promise{try{await this.work.execute(Reflect.get(this.context,'identity.principal') as TrustedPrincipal|null,InstalledTokens.invoker.parse(Reflect.get(this.context,InstalledTokens.invoker.id)),AdminToken.parse(Reflect.get(this.context,AdminToken.id)),IdentitySessionsToken.parse(Reflect.get(this.context,IdentitySessionsToken.id)));}catch{this.work.unavailable();}}} +class RequestWork { + response:Response|null=null; + constructor(private readonly request:Request,private readonly settings:ServerConfiguration,private readonly handle:CompositionHandle){} + async execute(principal:TrustedPrincipal|null,invoker:PlatformInvoker,admin:AdminContribution,sessions:IdentitySessions):Promise{const url=new URL(this.request.url);if(url.pathname==='/health/ready'&&this.request.method==='GET'){this.handle.healthy();let ready=await sessions.ready();const contexts=[];for(const token of [ApiHostToken,InstalledTokens.invoker,IdentitySessionsToken,AdminToken] as CapabilityToken[])for(const provider of this.handle.describe(token)){if(!provider.selected||!provider.ready)ready=false;contexts.push({capability:token.id,provider:provider.id,ready:provider.ready});}this.response=RequestWork.json({ready,nativeComposition:Context.is(this.handle.context),installationId:this.settings.installationId,assetDigest:admin.assetDigest(),generation:this.handle.generation,contexts},ready?200:503);return;}const contribution=await admin.handle(this.request);if(contribution){this.response=contribution;return;}if(!url.pathname.startsWith('/v1/platform')){this.response=RequestWork.json({code:'NOT_FOUND'},404);return;}if(!principal){this.response=RequestWork.json({code:'AUTHENTICATION_REQUIRED'},401);return;}try{let operation:string|null=null,input:unknown={};if(this.request.method==='GET'&&url.pathname==='/v1/platform')operation='platform.describe';else if(this.request.method==='GET'&&url.pathname==='/v1/platform/status')operation='platform.status';else if(this.request.method==='GET'&&/^\/v1\/platform\/applications\/app-[a-f0-9]{64}$/.test(url.pathname)){operation='platform.applications.get';input={id:url.pathname.split('/').pop()!};}else for(const binding of PlatformOperations.all)if(binding.definition.rest.path===url.pathname&&binding.definition.rest.method===this.request.method){operation=binding.definition.id;if(this.request.method==='POST'){try{input=ApiJson.parse(new Uint8Array(await this.request.arrayBuffer()));}catch{throw new InputRejected();}}}if(!operation){this.response=RequestWork.json({code:'NOT_FOUND'},404);return;}if(url.searchParams.size>0){this.response=RequestWork.json({code:'INVALID_INPUT'},400);return;}this.response=RequestWork.json(await invoker.invoke(operation,input,principal));}catch(error){if(error instanceof InputRejected)this.response=RequestWork.json({code:'INVALID_INPUT'},400);else if(error instanceof AuthorityDenied)this.response=RequestWork.json({code:'AUTHORITY_DENIED'},403);else if(error instanceof OperationUnavailable)this.response=RequestWork.json({code:'OPERATION_UNAVAILABLE'},501);else if(error instanceof PlanningBlockedError&&error.blockers.some(RequestWork.missingApplication))this.response=RequestWork.json({code:'APPLICATION_NOT_FOUND'},404);else this.response=RequestWork.json({code:'REQUEST_FAILED'},503);}} + private static missingApplication(blocker:{code:string}):boolean{return blocker.code==='APPLICATION_NOT_FOUND';} + unavailable():void{this.response=RequestWork.json({code:'SERVICE_UNAVAILABLE'},503);} + static json(value:unknown,status=200):Response{return new Response(JSON.stringify(value),{status,headers:{'Content-Type':'application/json','Cache-Control':'no-store','X-Content-Type-Options':'nosniff'}});} +} +class Router implements ApiRouter { + constructor(private readonly context:Context,private readonly settings:ServerConfiguration){} + async handle(request:Request):Promise{let sessions:ProviderLease|null=null,admin:ProviderLease|null=null,invoker:ProviderLease|null=null;let principalFiber:ReturnType|null=null,actionFiber:ReturnType|null=null;try{const locator=CordisServiceLocator.from(this.context);sessions=await locator.acquire(IdentitySessionsToken);admin=await locator.acquire(AdminToken);invoker=await locator.acquire(InstalledTokens.invoker);const cookie=AdminRoutes.cookie(request.headers.get('cookie')??'',AdminRoutes.sessionCookieName(this.settings)),principal=await sessions.value.principal(cookie),child=this.context.isolate('identity.principal',Symbol()),work=new RequestWork(request,this.settings,Reflect.get(this.context,'cordis.composition') as CompositionHandle);principalFiber=child.plugin(RequestPrincipalPlugin,principal);await principalFiber.await();if(principalFiber.state!==FiberState.ACTIVE)throw new Error('Request scope unavailable');actionFiber=child.plugin(RequestActionPlugin,work);await actionFiber.await();if(actionFiber.state!==FiberState.ACTIVE||!work.response)throw new Error('Request scope failed');return work.response;}catch{return RequestWork.json({code:'SERVICE_UNAVAILABLE'},503);}finally{await actionFiber?.dispose();await principalFiber?.dispose();await invoker?.release();await admin?.release();await sessions?.release();}} +} +class RouterPlugin { + static readonly declaration=Declaration.define('installed-routes',ApiRouterToken.id,['cordis.composition',IdentitySessionsToken.id,AdminToken.id,InstalledTokens.invoker.id]);static readonly provide=this.declaration.provide;static readonly inject=this.declaration.inject;static readonly Config=InstalledSchemas.serverConfiguration; + constructor(context:Context,settings:ServerConfiguration){context.provide(ApiRouterToken.id,new Router(context,settings));} +} +class HttpExchange { + private readonly chunks:Buffer[]=[];private size=0; + constructor(private readonly incoming:IncomingMessage,private readonly outgoing:ServerResponse,private readonly router:ApiRouter,private readonly origin:string){incoming.setTimeout(10000,this.abort.bind(this));incoming.on('error',this.abort.bind(this));incoming.on('data',this.data.bind(this));incoming.once('end',this.end.bind(this));} + private data(bytes:Buffer):void{this.size+=bytes.length;if(this.size>65536){this.outgoing.writeHead(413);this.outgoing.end();this.incoming.destroy();return;}this.chunks.push(bytes);} + private end():void{void this.respond();} + private async respond():Promise{try{const target=this.incoming.url??'/';if(!target.startsWith('/')||target.startsWith('//')||this.incoming.headers['content-encoding']){this.outgoing.writeHead(400);this.outgoing.end();return;}const headers=new Headers();for(const [key,value] of Object.entries(this.incoming.headers))if(value!==undefined){if(Array.isArray(value)){for(const item of value)headers.append(key,item);}else headers.set(key,value);}const method=this.incoming.method??'GET',request=new Request(new URL(target,this.origin),{method,headers,body:method==='GET'||method==='HEAD'?undefined:new Uint8Array(Buffer.concat(this.chunks))}),response=await this.router.handle(request);this.outgoing.statusCode=response.status;for(const [key,value] of response.headers)if(key.toLowerCase()!=='set-cookie')this.outgoing.setHeader(key,value);const cookies=response.headers.getSetCookie();if(cookies.length)this.outgoing.setHeader('Set-Cookie',cookies);const bytes=await response.arrayBuffer();this.outgoing.end(new Uint8Array(bytes));}catch{if(!this.outgoing.headersSent)this.outgoing.writeHead(503,{'Content-Type':'application/json'});this.outgoing.end('{"code":"SERVICE_UNAVAILABLE"}');}} + private abort():void{this.outgoing.destroy();this.incoming.destroy();} +} +class HttpListener implements ApiListener { + private readonly server:Server; + constructor(private readonly router:ApiRouter,private readonly settings:ServerConfiguration){this.server=createServer({requestTimeout:10000,headersTimeout:10000,maxHeaderSize:16384},this.exchange.bind(this));} + private exchange(request:IncomingMessage,response:ServerResponse):void{new HttpExchange(request,response,this.router,this.settings.origin);} + async start():Promise{await new Promise(this.listen.bind(this));} + private listen(resolveResult:(value:void)=>void,rejectResult:(error:Error)=>void):void{this.server.once('error',rejectResult);this.server.listen(8080,'0.0.0.0',resolveResult);} + address():string{const address=this.server.address();if(!address||typeof address==='string')throw new Error('Listener unavailable');return address.address+':'+address.port;} + async close():Promise{this.server.closeIdleConnections();await new Promise(this.stop.bind(this));} + private stop(resolveResult:(value:void)=>void):void{this.server.close(this.closed.bind(this,resolveResult));} + private closed(resolveResult:(value:void)=>void):void{resolveResult();} +} +class HttpPlugin { + static readonly declaration=Declaration.define('node-http',ApiHttpToken.id,[ApiRouterToken.id]);static readonly provide=this.declaration.provide;static readonly inject=this.declaration.inject;static readonly Config=InstalledSchemas.serverConfiguration; + private readonly listener:HttpListener; + constructor(private readonly context:Context,settings:ServerConfiguration){this.listener=new HttpListener(ApiRouterToken.parse(Reflect.get(context,ApiRouterToken.id)),settings);} + async [Service.init]():Promise<()=>Promise>{await this.listener.start();this.context.provide(ApiHttpToken.id,this.listener);return this.listener.close.bind(this.listener);} +} +export class ApiApplication { + private handle:CompositionHandle|null=null;readonly settings:ServerConfiguration; + constructor(settings:unknown){this.settings=InstalledSchemas.serverConfiguration.parse(settings);} + async start():Promise{if(this.handle)throw new Error('API is already started');const artifact:ProviderArtifact={kind:'bundled',identity:'sha256:'+ApiBuild.runtimeIdentity(this.settings.assetDigest)},scopeId='api:'+this.settings.installationId,registrations:ProviderRegistration[]=[{token:ApiHostToken,plugin:HostPlugin,declaration:HostPlugin.declaration,configuration:this.settings,artifact},{token:InstalledTokens.invoker,plugin:InvokerPlugin,declaration:InvokerPlugin.declaration,configuration:this.settings,artifact},{token:ApiRouterToken,plugin:RouterPlugin,declaration:RouterPlugin.declaration,configuration:this.settings,artifact},{token:ApiHttpToken,plugin:HttpPlugin,declaration:HttpPlugin.declaration,configuration:this.settings,artifact},...IdentityComposition.registrations(this.settings,artifact),...AdminComposition.registrations(this.settings,artifact)],selections=[];for(const registration of registrations){const declaration=ProviderSchemas.declaration.parse(registration.declaration);selections.push({scopeId,capability:declaration.capability,providerId:declaration.id});}this.handle=await CordisComposition.start({registrations,selections},{scopeId,target:'api',principalId:'installation:'+this.settings.installationId,resourceScope:this.settings.installationId});} + async close():Promise{await this.handle?.dispose();this.handle=null;} + static configurationFile(path:string):ServerConfiguration{if(!path.startsWith('/')||resolve(path)!==path)throw new Error('Configuration path refused');let current=path;while(true){if(lstatSync(current).isSymbolicLink())throw new Error('Configuration symlink refused');const parent=dirname(current);if(parent===current)break;current=parent;}const fd=openSync(path,constants.O_RDONLY|constants.O_NOFOLLOW|constants.O_NONBLOCK);try{const before=fstatSync(fd);if(!before.isFile()||before.nlink!==1||before.uid!==process.getuid?.()||(before.mode&0o777)!==0o600||before.size>1024*1024)throw new Error('Private configuration refused');const bytes=readFileSync(fd),after=fstatSync(fd),linked=lstatSync(path);if(before.ino!==after.ino||before.dev!==after.dev||before.size!==after.size||before.mtimeMs!==after.mtimeMs||before.ctimeMs!==after.ctimeMs||before.ino!==linked.ino||before.dev!==linked.dev||linked.isSymbolicLink())throw new Error('Configuration changed');return InstalledSchemas.serverConfiguration.parse(ApiJson.parse(bytes));}finally{closeSync(fd);}} +} +export class ApiBuild { + static metadata():ProviderDeclaration{return NodeApiContainerPlugin.declaration;} + static providers():ProviderDeclaration[]{return [HostPlugin.declaration,InvokerPlugin.declaration,RouterPlugin.declaration,HttpPlugin.declaration];} + static runtimeIdentity(assetDigest:string):string{const path='/app/cordis-runtime.json',fd=openSync(path,constants.O_RDONLY|constants.O_NOFOLLOW|constants.O_NONBLOCK);try{const stat=fstatSync(fd);if(!stat.isFile()||stat.nlink!==1||stat.size>1024*1024||(stat.mode&0o022)!==0||![0,process.getuid?.()].includes(stat.uid))throw new Error('Runtime manifest refused');const manifest=ApiJson.parse(readFileSync(fd)) as {formatVersion?:unknown;baseImage?:unknown;packageDigest?:unknown;assetDigest?:unknown;packages?:unknown};if(manifest.formatVersion!==1||manifest.assetDigest!==assetDigest||!PlatformSchemas.imageReference.safeParse(manifest.baseImage).success||!Array.isArray(manifest.packages))throw new Error('Runtime identity refused');const packages=[];for(const value of manifest.packages){const artifact=PlatformSchemas.resolvedArtifact.parse(value);if(artifact.kind!=='package')throw new Error('Runtime identity refused');packages.push(artifact);}if(CanonicalJson.digest(packages)!==manifest.packageDigest)throw new Error('Runtime package identity refused');return CanonicalJson.digest({baseImage:manifest.baseImage,packageDigest:manifest.packageDigest,runtime:process.version});}finally{closeSync(fd);}} + static async imageContext(directory:string,baseImage:string,packages:Array<{name:string;version:string;path:string;sha256:string}>):Promise<{packageDigest:string;assetDigest:string}>{if(!directory.startsWith('/')||resolve(directory)!==directory||!PlatformSchemas.imageReference.safeParse(baseImage).success)throw new Error('Image context refused');const stat=lstatSync(directory);if(!stat.isDirectory()||stat.isSymbolicLink()||stat.uid!==process.getuid?.()||(stat.mode&0o777)!==0o700)throw new Error('Image context ownership refused');const runtime=join(directory,'runtime');mkdirSync(runtime,{mode:0o755});mkdirSync(join(runtime,'node_modules'),{mode:0o755});const artifacts=[];packages.sort(ApiBuild.packageOrder);const seen=new Set();for(const item of packages){PlatformSchemas.packageName.parse(item.name);PlatformSchemas.version.parse(item.version);PlatformSchemas.digest.parse(item.sha256);if(seen.has(item.name))throw new Error('Duplicate runtime package');seen.add(item.name);const target=join(runtime,'node_modules',item.name);if(item.name.startsWith('@')){const scope=dirname(target);try{mkdirSync(scope,{mode:0o755});}catch(error){if((error as NodeJS.ErrnoException).code!=='EEXIST')throw error;}}mkdirSync(target,{mode:0o700});await PackageArchiveMaterializer.materialize(item.path,item.sha256,target);const metadata=ApiJson.parse(readFileSync(join(target,'package.json'))) as {name?:unknown;version?:unknown};if(metadata.name!==item.name||metadata.version!==item.version)throw new Error('Runtime package identity refused');artifacts.push({kind:'package',name:item.name,version:item.version,sha256:item.sha256});}const admin=join(runtime,'node_modules/@treeseed-cordis/admin'),metadata=ApiJson.parse(readFileSync(join(admin,'package.json'))) as {cordisAsset?:unknown},asset=PlatformSchemas.file.parse(metadata.cordisAsset),assetDigest=createHash('sha256').update(readFileSync(join(admin,asset.path))).digest('hex');if(asset.sha256!==assetDigest)throw new Error('Admin asset refused');const packageDigest=CanonicalJson.digest(artifacts),dockerfile='FROM '+baseImage+'\nWORKDIR /app\nCOPY runtime/ /app/\nLABEL ai.treeseed.cordis.package-set="'+packageDigest+'" ai.treeseed.cordis.admin-asset="'+assetDigest+'"\nENTRYPOINT ["node","/app/node_modules/@treeseed-cordis/api/dist/src/server.js"]\nCMD ["--serve","--configuration","/run/secrets/api-config"]\n';writeFileSync(join(runtime,'cordis-runtime.json'),CanonicalJson.stringify({formatVersion:1,baseImage,packageDigest,assetDigest,packages:artifacts})+'\n',{mode:0o644,flag:'wx'});writeFileSync(join(directory,'Dockerfile'),dockerfile,{mode:0o600,flag:'wx'});return {packageDigest,assetDigest};} + private static packageOrder(a:{name:string},b:{name:string}):number{return a.nameb.name?1:0;} +} +class ApiProgram { + private static application:ApiApplication|null=null; + static async run():Promise{if(!import.meta.main)return;const args=process.argv.slice(2);if(args.length===1&&args[0]==='--describe'){process.stdout.write(JSON.stringify({container:ApiBuild.metadata(),providers:ApiBuild.providers()})+'\n');return;}if(args.length===1&&args[0]==='--metadata'){const metadata=ApiJson.parse(readFileSync('package.json')) as Record;metadata.cordis=ApiBuild.metadata();metadata.cordisService=NodeApiContainerPlugin.definition;metadata.cordisProviders=ApiBuild.providers();writeFileSync('package.json',JSON.stringify(metadata,null,2)+'\n');return;}if(args.length===2&&args[0]==='--image-context'){try{const input=ApiJson.parse(readFileSync(args[1]!)) as {directory:string;baseImage:string;packages:Array<{name:string;version:string;path:string;sha256:string}>};process.stdout.write(JSON.stringify(await ApiBuild.imageContext(input.directory,input.baseImage,input.packages))+'\n');}catch{process.stderr.write('Image context preparation failed.\n');process.exitCode=1;}return;}let settings:ServerConfiguration;try{if(args.length!==3||args[0]!=='--serve'||args[1]!=='--configuration')throw new Error('Arguments refused');settings=ApiApplication.configurationFile(args[2]!);}catch{process.stderr.write('Invalid API configuration or arguments.\n');process.exitCode=2;return;}try{this.application=new ApiApplication(settings);await this.application.start();process.once('SIGTERM',ApiProgram.close);process.once('SIGINT',ApiProgram.close);}catch{await this.application?.close();process.stderr.write('API prerequisites or composition failed.\n');process.exitCode=3;}} + private static async close():Promise{await ApiProgram.application?.close();} +} +await ApiProgram.run(); diff --git a/tests/server.test.ts b/tests/server.test.ts new file mode 100644 index 000000000..1eeb57443 --- /dev/null +++ b/tests/server.test.ts @@ -0,0 +1,13 @@ +import {test,expect} from 'bun:test'; +import {spawnSync} from 'node:child_process'; +import {mkdtempSync,writeFileSync,rmSync,readFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {ApiBuild,ApiApplication} from '../src/server.js'; +class ApiTests { + program():void{const result=spawnSync('node',['dist/src/server.js','--describe'],{encoding:'utf8',timeout:5000});expect(result.status).toBe(0);expect(result.stderr).toBe('');const metadata=JSON.parse(result.stdout);expect(metadata.container.capability).toBe('container.api');expect(metadata.providers).toEqual(ApiBuild.providers());} + invalidConfiguration():void{const directory=mkdtempSync(join(tmpdir(),'cordis api program ')),file=join(directory,'private.json');try{writeFileSync(file,JSON.stringify({secret:'API_SECRET_CANARY'}),{mode:0o600});const result=spawnSync('node',['dist/src/server.js','--serve','--configuration',file],{encoding:'utf8',timeout:5000});expect(result.status).toBe(2);expect(result.stdout+result.stderr).not.toContain('API_SECRET_CANARY');expect(ApiApplication.configurationFile.bind(ApiApplication,file)).toThrow();}finally{rmSync(directory,{recursive:true,force:true});}} + metadata():void{const actual=JSON.parse(readFileSync('package.json','utf8'));expect(actual.cordis).toEqual(ApiBuild.metadata());expect(ApiBuild.providers().map(ApiTests.capability)).toEqual(['api.host','platform.invoker','api.routes','api.http']);} + static capability(value:{capability:string}):string{return value.capability;} +} +const suite=new ApiTests();test('smoke: built API program declares real native roots and container',suite.program.bind(suite));test('unit: unconfigured API refuses credentials without output or listener',suite.invalidConfiguration.bind(suite));test('integration: API package projects actual owner plugin declarations',suite.metadata.bind(suite)); diff --git a/vendor/admin.tgz b/vendor/admin.tgz new file mode 100644 index 000000000..34eb7bea4 Binary files /dev/null and b/vendor/admin.tgz differ diff --git a/vendor/deployment.tgz b/vendor/deployment.tgz new file mode 100644 index 000000000..678cb153a Binary files /dev/null and b/vendor/deployment.tgz differ diff --git a/vendor/identity.tgz b/vendor/identity.tgz new file mode 100644 index 000000000..5d088ab35 Binary files /dev/null and b/vendor/identity.tgz differ diff --git a/vendor/sdk.tgz b/vendor/sdk.tgz index ab562d4f9..a5cf0afc6 100644 Binary files a/vendor/sdk.tgz and b/vendor/sdk.tgz differ