@hanamesh/dsh-app-host 0.2.0-rc.2

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 (82) hide show
  1. package/LICENSE +31 -0
  2. package/README.md +104 -0
  3. package/consistency.json +101 -0
  4. package/dist/client-ui.d.ts +19 -0
  5. package/dist/client-ui.js +271 -0
  6. package/dist/client.d.ts +16 -0
  7. package/dist/client.js +42 -0
  8. package/dist/consistency.js +12 -0
  9. package/dist/descriptor.js +141 -0
  10. package/dist/dsh.d.ts +25 -0
  11. package/dist/dsh.js +194 -0
  12. package/dist/errors.js +25 -0
  13. package/dist/gateway.js +224 -0
  14. package/dist/guardian.js +113 -0
  15. package/dist/index.d.ts +130 -0
  16. package/dist/index.js +15 -0
  17. package/dist/launcher.js +17 -0
  18. package/dist/library/catalog.js +108 -0
  19. package/dist/library/domain.js +8 -0
  20. package/dist/library/install.js +87 -0
  21. package/dist/library/installed.js +67 -0
  22. package/dist/library/locate.js +79 -0
  23. package/dist/library/routes.js +17 -0
  24. package/dist/library/service.js +76 -0
  25. package/dist/manager.js +605 -0
  26. package/dist/provision/LICENSE +6 -0
  27. package/dist/provision/THIRD_PARTY_NOTICES.md +13 -0
  28. package/dist/provision/archive.d.ts +28 -0
  29. package/dist/provision/archive.js +66 -0
  30. package/dist/provision/check.d.ts +10 -0
  31. package/dist/provision/check.js +51 -0
  32. package/dist/provision/checkpoints.d.ts +12 -0
  33. package/dist/provision/checkpoints.js +23 -0
  34. package/dist/provision/cli.d.ts +1 -0
  35. package/dist/provision/cli.js +169 -0
  36. package/dist/provision/download.d.ts +21 -0
  37. package/dist/provision/download.js +242 -0
  38. package/dist/provision/errors.d.ts +11 -0
  39. package/dist/provision/errors.js +13 -0
  40. package/dist/provision/extract.d.ts +17 -0
  41. package/dist/provision/extract.js +124 -0
  42. package/dist/provision/fsutil.d.ts +8 -0
  43. package/dist/provision/fsutil.js +71 -0
  44. package/dist/provision/index.d.ts +7 -0
  45. package/dist/provision/index.js +6 -0
  46. package/dist/provision/ledger.d.ts +14 -0
  47. package/dist/provision/ledger.js +50 -0
  48. package/dist/provision/manifest.d.ts +11 -0
  49. package/dist/provision/manifest.js +171 -0
  50. package/dist/provision/paths.d.ts +27 -0
  51. package/dist/provision/paths.js +95 -0
  52. package/dist/provision/platform.d.ts +6 -0
  53. package/dist/provision/platform.js +7 -0
  54. package/dist/provision/provision.d.ts +17 -0
  55. package/dist/provision/provision.js +454 -0
  56. package/dist/provision/tar.d.ts +7 -0
  57. package/dist/provision/tar.js +169 -0
  58. package/dist/provision/types.d.ts +174 -0
  59. package/dist/provision/types.js +7 -0
  60. package/dist/provision/zip.d.ts +4 -0
  61. package/dist/provision/zip.js +194 -0
  62. package/dist/router/broker.js +161 -0
  63. package/dist/router/domain.js +18 -0
  64. package/dist/router/receipts-domain.js +8 -0
  65. package/dist/router/receipts.js +156 -0
  66. package/dist/router/routes.js +48 -0
  67. package/dist/router/sources.js +87 -0
  68. package/dist/routes.js +74 -0
  69. package/dist/runtime.js +222 -0
  70. package/dist/store.js +250 -0
  71. package/dist/usage-evidence.js +117 -0
  72. package/docs/APP_PACKAGE.md +38 -0
  73. package/docs/CONTRACT.md +190 -0
  74. package/docs/DSH_INTEGRATION.md +43 -0
  75. package/docs/LIBRARY.md +45 -0
  76. package/docs/LICENSES.json +185 -0
  77. package/docs/PROVENANCE.json +63 -0
  78. package/docs/SECURITY.md +52 -0
  79. package/package.json +112 -0
  80. package/profile/cordis.patch.yml +4 -0
  81. package/schemas/catalog-provider-page.schema.json +376 -0
  82. package/schemas/catalog-source.schema.json +151 -0
package/dist/client.js ADDED
@@ -0,0 +1,42 @@
1
+ /** Workspace-side SDK only. Never inject this module into an application iframe. */
2
+ export class WorkspaceAppClient {
3
+ constructor({origin=globalThis.location?.origin,fetch:fetcher=globalThis.fetch,headers=()=>({})}={}) {
4
+ const parsed=new URL(origin);
5
+ if(parsed.origin!==origin||parsed.protocol!=='http:'||parsed.hostname!=='127.0.0.1'||!parsed.port)throw new TypeError('Expected exact loopback workspace origin.');
6
+ if(typeof fetcher!=='function'||typeof headers!=='function')throw new TypeError('fetch and host headers provider are required.');
7
+ this.origin=origin;this.fetcher=fetcher;this.headers=headers;
8
+ }
9
+ async request(path,input,signal){
10
+ // Browsers refuse `fetch` invoked with a foreign `this`; keep the global receiver for the default fetcher.
11
+ const response=await this.fetcher.call(globalThis,this.origin+path,{method:input===undefined?'GET':'POST',credentials:'same-origin',redirect:'error',signal,
12
+ headers:{...await this.headers(),...(input===undefined?{}:{'content-type':'application/json','x-hanamesh-client':'workspace-v1'})},
13
+ ...(input===undefined?{}:{body:JSON.stringify(input)})});
14
+ const body=await response.json();
15
+ if(!response.ok){const error=new Error(body.error?.message??'App-host request failed.');error.code=body.error?.code??'HTTP_ERROR';error.details=body.error?.details;error.status=response.status;throw error;}
16
+ return body;
17
+ }
18
+ list(signal){return this.request('/hanamesh/apps',undefined,signal);}
19
+ open(input,signal){return this.request('/apps/open',input,signal);}
20
+ resume(lease,signal){return this.request('/apps/resume',lease,signal);}
21
+ recoverView(input,signal){return this.request('/apps/recover',input,signal);}
22
+ close(lease,signal){return this.request('/apps/close',lease,signal);}
23
+ heartbeat(lease,signal){return this.request('/apps/heartbeat',lease,signal);}
24
+ stop(instanceId,{confirm=false,signal}={}){return this.request('/apps/stop',{instanceId,confirm},signal);}
25
+ events(after=0,signal){if(!Number.isSafeInteger(after)||after<0)throw new TypeError('Invalid event cursor.');return this.request(`/apps/events?after=${after}`,undefined,signal);}
26
+ // A bounded explicit operation; never retries a new Open without the receipt's token.
27
+ async waitUntilReady(receipt,{timeoutMs=15_000,pollMs=100,signal}={}){
28
+ if(!Number.isFinite(timeoutMs)||timeoutMs<=0||!Number.isFinite(pollMs)||pollMs<20)throw new TypeError('Invalid polling bounds.');
29
+ const deadline=Date.now()+timeoutMs;let current=receipt;
30
+ while(current.instance.status!=='ready'){
31
+ signal?.throwIfAborted();
32
+ if(['failed','stopped','interrupted','stopping'].includes(current.instance.status))throw new Error(`Instance is ${current.instance.status}.`);
33
+ if(Date.now()>=deadline)throw new Error('Readiness wait timed out; use the receipt to close or inspect this view.');
34
+ await new Promise(resolve=>setTimeout(resolve,pollMs));
35
+ const listed=await this.list(signal),found=listed.instances.find(i=>i.id===receipt.instance.id);
36
+ if(!found)throw new Error('Persisted instance is missing.');
37
+ if(found.status==='ready')current=await this.resume({viewId:receipt.lease.viewId,leaseToken:receipt.leaseToken},signal);
38
+ else current={...current,instance:found};
39
+ }
40
+ return current;
41
+ }
42
+ }
@@ -0,0 +1,12 @@
1
+ // These two orderings are used by the production manager AND the real SIGKILL tests.
2
+ // Keep fault injection as a trusted constructor dependency, never a browser option.
3
+ export async function reserveBeforeLaunch(publish, launch, checkpoint = async () => {}) {
4
+ await publish(); // ORDER:RESERVE
5
+ await checkpoint('reservation-durable');
6
+ return await launch(); // ORDER:LAUNCH
7
+ }
8
+ export async function stopBeforePublish(stop, publish, checkpoint = async () => {}) {
9
+ await stop(); // ORDER:STOP
10
+ await checkpoint('runtime-stopped');
11
+ return await publish(); // ORDER:STOPPED-PUBLISH
12
+ }
@@ -0,0 +1,141 @@
1
+ import { isAbsolute } from 'node:path';
2
+ import { createHash } from 'node:crypto';
3
+ import { requireCondition, copy } from './errors.js';
4
+ export function identifier(value, name = 'id') {
5
+ requireCondition(typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(value) &&
6
+ !['__proto__','prototype','constructor'].includes(value), 'INVALID_ID', `Invalid ${name}.`);
7
+ return value;
8
+ }
9
+ export function loopbackOrigin(value) {
10
+ let url;
11
+ try { url = new URL(value); } catch { requireCondition(false, 'INVALID_ORIGIN', 'Expected an explicit loopback origin.'); }
12
+ requireCondition(url.protocol === 'http:' && url.hostname === '127.0.0.1' && url.port &&
13
+ !url.username && !url.password && url.pathname === '/' && !url.search && !url.hash,
14
+ 'INVALID_ORIGIN', 'Only an explicit http://127.0.0.1:<port> origin is supported by this candidate.');
15
+ return url.origin;
16
+ }
17
+ /** npm package name (optionally scoped), at most 128 characters — the same shape the usage plugin accepts as `hanaRef`. */
18
+ export const packageName = value => typeof value === 'string' && value.length <= 128 && /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(value);
19
+ export function validateDefinition(input) {
20
+ const d = copy(input);
21
+ identifier(d.id, 'appId');
22
+ requireCondition(typeof d.name === 'string' && d.name.length > 0 && d.name.length <= 160, 'INVALID_DEFINITION', 'Application name is required.');
23
+ // rc.27: the npm package that ships this app (usage evidence `hanaRef`). Optional — the installed scan supplies it otherwise.
24
+ if (d.packageName !== undefined) requireCondition(packageName(d.packageName), 'INVALID_DEFINITION', 'packageName must be an npm package name.');
25
+ d.singleInstanceOnly ??= true;
26
+ requireCondition(typeof d.singleInstanceOnly === 'boolean' && Array.isArray(d.deployments) && d.deployments.length > 0,
27
+ 'INVALID_DEFINITION', 'Expected deployment definitions.');
28
+ const ids = new Set();
29
+ for (const p of d.deployments) {
30
+ identifier(p.id, 'deploymentId'); identifier(p.dataId, 'dataId');
31
+ requireCondition(!ids.has(p.id), 'INVALID_DEFINITION', 'Duplicate deploymentId.'); ids.add(p.id);
32
+ requireCondition(['owned','attach'].includes(p.mode), 'INVALID_DEFINITION', 'mode must be owned or attach.');
33
+ p.embedding ??= 'direct';
34
+ requireCondition(['direct','gateway'].includes(p.embedding), 'INVALID_DEFINITION', 'Unknown embedding mode.');
35
+ const r = p.readiness;
36
+ requireCondition(r && typeof r.path === 'string' && r.path.startsWith('/') && !r.path.startsWith('//') &&
37
+ !r.path.includes('\\') && Number.isInteger(r.status) && r.status >= 200 && r.status < 500 &&
38
+ ((typeof r.bodyIncludes === 'string' && r.bodyIncludes.length > 0) ||
39
+ (r.header && typeof r.header.name === 'string' && typeof r.header.value === 'string' && r.header.value.length > 0)),
40
+ 'INVALID_READINESS', 'Readiness requires an exact status and nonempty application identity marker.');
41
+ p.startTimeoutMs ??= 10_000; p.stopGraceMs ??= 1_000;
42
+ requireCondition(Number.isInteger(p.startTimeoutMs) && p.startTimeoutMs >= 100 && p.startTimeoutMs <= 120_000 &&
43
+ Number.isInteger(p.stopGraceMs) && p.stopGraceMs >= 50 && p.stopGraceMs <= 30_000,
44
+ 'INVALID_DEFINITION', 'Lifecycle timeouts are outside the allowed bounds.');
45
+ if (p.mode === 'owned') {
46
+ if (p.runtime !== undefined) {
47
+ requireCondition(p.runtime && typeof p.runtime === 'object' && !Array.isArray(p.runtime) && p.command === undefined,
48
+ 'INVALID_RUNTIME', 'A runtime deployment must omit command and declare a runtime object.');
49
+ const { manifest, item, exec } = p.runtime;
50
+ requireCondition(manifest && typeof manifest === 'object' && !Array.isArray(manifest) && manifest.schema === 1 &&
51
+ Array.isArray(manifest.sources) && Array.isArray(manifest.items) && typeof item === 'string',
52
+ 'INVALID_RUNTIME', 'runtime.manifest must be a schema 1 provision manifest.');
53
+ requireCondition(manifest.sources.every(source => source && typeof source === 'object' &&
54
+ typeof source.id === 'string' && ['https','file'].includes(source.kind) && typeof source.base === 'string'),
55
+ 'INVALID_RUNTIME', 'runtime manifest sources are invalid.');
56
+ const selected = manifest.items.find(entry => entry?.id === item);
57
+ requireCondition(selected && typeof selected.version === 'string' && selected.version.length > 0 &&
58
+ typeof selected.installTo === 'string' && safeRelativePosix(selected.installTo) &&
59
+ selected.platforms && typeof selected.platforms === 'object' && !Array.isArray(selected.platforms),
60
+ 'INVALID_RUNTIME', 'runtime.item must select a valid manifest item.');
61
+ requireCondition(typeof exec === 'string' && safeRelativePosix(exec),
62
+ 'INVALID_RUNTIME', 'runtime.exec must be a relative POSIX path inside the selected runtime tree.');
63
+ } else {
64
+ requireCondition(typeof p.command === 'string' && isAbsolute(p.command) && !p.command.includes('\0'),
65
+ 'INVALID_COMMAND', 'An absolute, trusted executable path is required.');
66
+ }
67
+ requireCondition(Array.isArray(p.args) && p.args.every(a => typeof a === 'string' && !a.includes('\0')),
68
+ 'INVALID_COMMAND', 'args must be an explicit string array; shell execution is not supported.');
69
+ if (p.cwd) requireCondition(isAbsolute(p.cwd), 'INVALID_COMMAND', 'cwd must be absolute.');
70
+ p.env ??= {}; p.envAllowlist ??= [];
71
+ requireCondition(p.env && !Array.isArray(p.env) && Array.isArray(p.envAllowlist) &&
72
+ Object.entries(p.env).every(([k, v]) => /^[A-Z_][A-Z0-9_]*$/.test(k) && p.envAllowlist.includes(k) &&
73
+ typeof v === 'string' && !v.includes('\0') && !/^(DSH_|NODE_OPTIONS$|LD_|DYLD_)/.test(k)),
74
+ 'INVALID_ENV', 'Every environment entry must be explicitly allowlisted; host control variables are forbidden.');
75
+ // rc.4: an app declares WHICH credentials it needs (env names / home files), never where they come from.
76
+ p.credentialEnv ??= [];
77
+ requireCondition(Array.isArray(p.credentialEnv), 'INVALID_CREDENTIAL_ENV', 'credentialEnv must be an array.');
78
+ const seen = new Set();
79
+ for (const c of p.credentialEnv) {
80
+ requireCondition(c && typeof c === 'object' && !Array.isArray(c), 'INVALID_CREDENTIAL_ENV', 'credentialEnv entries must be objects.');
81
+ c.kind ??= 'api-key'; c.required ??= false; c.projection ??= 'env'; c.providers ??= [];
82
+ if (c.projection === 'file') { c.base ??= 'home'; }
83
+ requireCondition((c.projection !== 'file' || ['home','dataDir'].includes(c.base)) &&
84
+ (c.format === undefined || (typeof c.format === 'string' && /^[a-z0-9][a-z0-9.-]{0,63}$/.test(c.format))),
85
+ 'INVALID_CREDENTIAL_ENV', 'credentialEnv file base must be home or dataDir; format is an opaque lowercase id.');
86
+ requireCondition(['api-key','grant'].includes(c.kind) && ['env','file'].includes(c.projection) && typeof c.required === 'boolean' &&
87
+ Array.isArray(c.providers) && c.providers.every(x => typeof x === 'string' && /^[a-z0-9][a-z0-9_.-]{0,63}$/.test(x)) &&
88
+ (c.purpose === undefined || (typeof c.purpose === 'string' && c.purpose.length <= 120)),
89
+ 'INVALID_CREDENTIAL_ENV', 'credentialEnv entry has an unknown kind, projection, provider id or purpose.');
90
+ if (c.projection === 'env') {
91
+ requireCondition(typeof c.env === 'string' && /^[A-Z_][A-Z0-9_]*$/.test(c.env) && !/^(DSH_|NODE_OPTIONS$|LD_|DYLD_|PATH$|HOME$|TMPDIR$|XDG_)/.test(c.env),
92
+ 'INVALID_CREDENTIAL_ENV', 'credentialEnv env must be a POSIX identifier and not a host control variable.');
93
+ requireCondition(!(c.env in p.env), 'INVALID_CREDENTIAL_ENV', 'credentialEnv must not repeat a static env entry.');
94
+ requireCondition(!seen.has('env:' + c.env), 'INVALID_CREDENTIAL_ENV', 'Duplicate credentialEnv env name.'); seen.add('env:' + c.env);
95
+ } else {
96
+ requireCondition(typeof c.path === 'string' && c.path.length > 0 && c.path.length <= 200 && !c.path.startsWith('/') &&
97
+ !c.path.includes('\0') && c.path.split('/').every(seg => seg && seg !== '.' && seg !== '..'),
98
+ 'INVALID_CREDENTIAL_ENV', 'credentialEnv file path must be relative to the app HOME and contain no "." or ".." segments.');
99
+ requireCondition(!seen.has(`file:${c.base}:` + c.path), 'INVALID_CREDENTIAL_ENV', 'Duplicate credentialEnv file path.'); seen.add(`file:${c.base}:` + c.path);
100
+ }
101
+ // rc.8 `sets`: non-secret companion values the broker applies when THIS slot is granted (e.g. the app's own
102
+ // provider id). Each target must be a declared env slot of this deployment, so the injection allowlist holds.
103
+ if (c.sets !== undefined) {
104
+ requireCondition(c.sets && typeof c.sets === 'object' && !Array.isArray(c.sets) && Object.entries(c.sets).every(([k, v]) =>
105
+ /^[A-Z_][A-Z0-9_]*$/.test(k) && typeof v === 'string' && v.length <= 256 && !/[\0\r\n]/.test(v)),
106
+ 'INVALID_CREDENTIAL_ENV', 'credentialEnv sets must map POSIX env names to short single-line strings.');
107
+ }
108
+ }
109
+ for (const c of p.credentialEnv) for (const k of Object.keys(c.sets ?? {}))
110
+ requireCondition(seen.has('env:' + k), 'INVALID_CREDENTIAL_ENV', `credentialEnv sets "${k}", which is not a declared env slot of this deployment.`);
111
+ const bindings = [...p.args, ...Object.values(p.env)];
112
+ requireCondition(bindings.some(v => v.includes('{{dataDir}}')) && bindings.some(v => v.includes('{{port}}')),
113
+ 'MISSING_RUNTIME_BINDING', 'Application adapter must explicitly bind its data directory and loopback port.');
114
+ for (const s of bindings) for (const match of s.matchAll(/\{\{(.*?)\}\}/g)) {
115
+ requireCondition(['dataDir','port','instanceId','runtimeId','appId','deploymentId','dataId'].includes(match[1]),
116
+ 'INVALID_TEMPLATE', 'Unknown launch template.');
117
+ }
118
+ } else {
119
+ p.url = loopbackOrigin(p.url);
120
+ requireCondition(!p.command && !p.args && !p.env, 'INVALID_DEFINITION', 'Attach deployments cannot include launch instructions.');
121
+ }
122
+ p.gateway ??= {};
123
+ p.gateway.cookieAllowlist ??= [];
124
+ requireCondition(Array.isArray(p.gateway.cookieAllowlist) && p.gateway.cookieAllowlist.every(x =>
125
+ typeof x === 'string' && /^[A-Za-z0-9_-]+$/.test(x) && !/^dsh|^hm_/i.test(x)),
126
+ 'INVALID_DEFINITION', 'Gateway cookie allowlist must contain explicit application cookie names.');
127
+ p.gateway.allowAppAuthorization ??= false;
128
+ requireCondition(typeof p.gateway.allowAppAuthorization === 'boolean', 'INVALID_DEFINITION', 'Invalid Authorization policy.');
129
+ }
130
+ return d;
131
+ }
132
+ function safeRelativePosix(value) {
133
+ if (typeof value !== 'string' || value.length === 0 || value.startsWith('/') || /^[A-Za-z]:/.test(value) ||
134
+ value.includes('\\') || value.includes('\0')) return false;
135
+ const segments = value.split('/');
136
+ return segments.every(segment => segment.length > 0 && segment !== '.' && segment !== '..');
137
+ }
138
+ export const fingerprint = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
139
+ export function expand(template, values) {
140
+ return template.replace(/\{\{(dataDir|port|instanceId|runtimeId|appId|deploymentId|dataId)\}\}/g, (_, k) => String(values[k]));
141
+ }
package/dist/dsh.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ import type { AppHost, AppDefinition, AuthenticatedSubject, Awaitable, DshStorageBinding } from './index.js';
2
+ export const name: 'hanamesh-app-host';
3
+ export const DSH_TARGET: '0.2.0-rc.2';
4
+ export const inject: readonly ['webServer', 'storageDomain', 'connection'];
5
+ export const BROWSER_PRINCIPAL: 'dsh-browser';
6
+ export const ROUTES: readonly string[];
7
+ export const Config: unknown;
8
+ export const appHostDomainSpec: { readonly name: 'hanamesh_app_host'; readonly version: 1; readonly layout: 'single' };
9
+ export interface DshPluginConfig {
10
+ dataRoot?: string; parentOrigin?: string; nodeBinary?: string; router?:{codingOauth?:{mode?:'http'|'file'}};
11
+ /** `sources` undefined → the HanaMesh catalog source; explicit `[]` → none. `profileDir`/`profileName`/`dshBin` (and
12
+ * `nodeBinary`) are inferred on plain DSH when undefined — see docs/CONTRACT.md 「应用库配置」; explicit values win. */
13
+ /** rc.28: the same keys serve the HanaMesh market (plugins + applications; routes `/hanamesh/library*`, see docs/CONTRACT.md「市场」). */
14
+ library?:{fixture?:string;sources?:Array<{manifestUrl:string;enabled?:boolean}>;profileDir?:string;profileName?:string;dshBin?:string;registry?:string;allowPrerelease?:boolean};
15
+ applications?: AppDefinition[]; leaseTtlMs?: number; sweepIntervalMs?: number;
16
+ }
17
+ /** Adapt an open `ctx.storageDomain` handle (single layout, global snapshot) to the store binding. */
18
+ export function domainBinding(domain: { global: { get(): unknown; set(value: unknown): Promise<void> }; close(): Promise<void> }): DshStorageBinding;
19
+ /** Authentication through `ctx.connection.requestRejection`; DSH web is single-user. */
20
+ export function browserAuthentication(connection: { requestRejection(request: { headers: Record<string, unknown> }): unknown }): {
21
+ authenticate(request: unknown): Awaitable<AuthenticatedSubject | null>;
22
+ authorize(subject: AuthenticatedSubject, path: string, input: unknown): Awaitable<boolean>;
23
+ };
24
+ /** Cordis plugin entry; activates once webServer, storageDomain and connection are provided. */
25
+ export function apply(ctx: unknown, config: DshPluginConfig): Promise<void>;
package/dist/dsh.js ADDED
@@ -0,0 +1,194 @@
1
+ /**
2
+ * DSH plugin entry for @hanamesh/dsh-app-host, bound to the PINNED public APIs
3
+ * of the official DSH 0.2.0-rc.2 release:
4
+ *
5
+ * ctx.storageDomain.open(spec) @deepseek-ai/dsh-storage-domain — `single` layout, one
6
+ * global snapshot; `global.set()` is one whole-image publish
7
+ * ctx.webServer.register(route) @deepseek-ai/dsh-host-webserver — exact routes + disposer
8
+ * ctx.connection.requestRejection() @deepseek-ai/dsh-client-connection — Host/Origin fence and
9
+ * the browser session cookie; the same gate the /api channel uses
10
+ * ctx.provide / ctx.effect @deepseek-ai/cordis — service seat and disposal
11
+ * ctx.inject([optional], child) @deepseek-ai/cordis — optional sibling services (credentials, llm,
12
+ * hanameshOAuth, hanameshUsage) are read duck-typed when/if they appear
13
+ *
14
+ * `inject` lists all three services, so the plugin only activates once they exist: without the
15
+ * connection service there is no authentication and therefore no route (H11/H14 fail closed).
16
+ * Nothing here guesses an API name; every call above has a type declaration in the pinned package.
17
+ */
18
+ import z from '@deepseek-ai/schemastery';
19
+ import { z as zod } from 'zod';
20
+ import { join } from 'node:path';
21
+ import { createRequire } from 'node:module';
22
+ import { defineDomain } from '@deepseek-ai/dsh-storage-domain';
23
+ import { AppHost } from './manager.js';
24
+ import { DshDomainSnapshotStore, emptySnapshot } from './store.js';
25
+ import { createHttpHandler } from './routes.js';
26
+ import { requireCondition } from './errors.js';
27
+ import { routerDomainSpec } from './router/domain.js';
28
+ import { createProviderSources } from './router/sources.js';
29
+ import { createRouter } from './router/broker.js';
30
+ import { createRouterHttpHandler, ROUTER_ROUTES } from './router/routes.js';
31
+ import { libraryDomainSpec } from './library/domain.js';
32
+ import { createLibraryService } from './library/service.js';
33
+ import { createLibraryInstaller } from './library/install.js';
34
+ import { resolveLibraryLocations } from './library/locate.js';
35
+ import { createLibraryHttpHandler, LIBRARY_ROUTES } from './library/routes.js';
36
+ import { createUsageEvidence } from './usage-evidence.js';
37
+ import { createReceiptLedger } from './router/receipts.js';
38
+ import { receiptsDomainSpec } from './router/receipts-domain.js';
39
+
40
+ export const name = 'hanamesh-app-host';
41
+ export const DSH_TARGET = '0.2.0-rc.2';
42
+ export const inject = ['webServer', 'storageDomain', 'connection'];
43
+ /** One authenticated local browser session per profile; DSH web has no per-user principals. */
44
+ export const BROWSER_PRINCIPAL = 'dsh-browser';
45
+ /** Every route the HTTP handler answers; registered individually so nothing else is shadowed. */
46
+ export const ROUTES = Object.freeze(['/hanamesh/apps', '/apps/open', '/apps/resume', '/apps/recover',
47
+ '/apps/close', '/apps/heartbeat', '/apps/stop', '/apps/events']);
48
+
49
+ export const Config = z.object({
50
+ /** Absolute canonical directory that owns every instance data root (secureDirectory refuses symlinks). */
51
+ dataRoot: z.string().default(join(process.env.DSH_HOME ?? process.env.HOME ?? '.', 'data', 'hanamesh-apps')),
52
+ /** Exact loopback workspace origin; defaults to the running web server's own origin. */
53
+ parentOrigin: z.string(),
54
+ /** Extra origins allowed as frame ancestors of app views — the desktop shell's own webview origin
55
+ * (e.g. `tauri://localhost`) when the DSH workspace itself runs inside the shell's iframe. */
56
+ frameAncestors: z.array(z.string()),
57
+ /** Standalone Node executable used for guardian/launcher processes; required under Electron. */
58
+ nodeBinary: z.string(),
59
+ router: z.object({ codingOauth: z.object({ mode:z.string() }) }),
60
+ library: z.object({
61
+ fixture:z.string(),
62
+ /** Undefined (not configured) → the HanaMesh catalog source; an explicit `[]` → no source. schemastery would
63
+ * coerce a missing array to `[]`, so the union keeps "unset" distinguishable from "deliberately empty". */
64
+ sources:z.union([z.array(z.any()),z.const(undefined)]),
65
+ /** All four are set by the desktop shell overlay; on plain DSH they are inferred (see library/locate.js). */
66
+ profileDir:z.string(),profileName:z.string(),dshBin:z.string(),
67
+ registry:z.string(),allowPrerelease:z.boolean(),
68
+ }),
69
+ applications: z.array(z.any()).default([]),
70
+ leaseTtlMs: z.number(),
71
+ sweepIntervalMs: z.number(),
72
+ });
73
+
74
+ /** Domain declaration: the whole app-host snapshot is ONE global value, published atomically. */
75
+ const snapshotSchema = zod.object({
76
+ schema: zod.literal(1), revision: zod.number().int().nonnegative(), sequence: zod.number().int().nonnegative(),
77
+ instances: zod.array(zod.record(zod.string(), zod.unknown())),
78
+ leases: zod.array(zod.record(zod.string(), zod.unknown())),
79
+ events: zod.array(zod.record(zod.string(), zod.unknown())),
80
+ });
81
+ export const appHostDomainSpec = defineDomain({
82
+ name: 'hanamesh_app_host', version: 1, layout: 'single', tables: {},
83
+ global: { schema: snapshotSchema, initial: emptySnapshot() },
84
+ });
85
+
86
+ /** Adapt an open storage-domain handle to the store's single-image binding port. */
87
+ export function domainBinding(domain) {
88
+ return Object.freeze({
89
+ layout: 'single', domain: appHostDomainSpec.name,
90
+ readSnapshot: async () => domain.global.get(),
91
+ publishSnapshot: async snapshot => { await domain.global.set(snapshot); },
92
+ // Single-open per domain name is enforced by the facility at open(); nothing extra to take here.
93
+ acquireExclusive: async () => {},
94
+ releaseExclusive: async () => { await domain.close(); },
95
+ });
96
+ }
97
+
98
+ /** The authentication port: connection's fence + cookie decide, the plugin never parses a token. */
99
+ export function browserAuthentication(connection) {
100
+ requireCondition(typeof connection?.requestRejection === 'function', 'DSH_BINDING_REQUIRED',
101
+ 'ctx.connection.requestRejection is required for authenticated app-host routes.');
102
+ return {
103
+ authenticate: async request => connection.requestRejection(request) === undefined
104
+ ? { principalId: BROWSER_PRINCIPAL } : null,
105
+ // DSH web is single-user: an authenticated browser session may perform every app-host operation.
106
+ authorize: async (subject) => subject?.principalId === BROWSER_PRINCIPAL,
107
+ };
108
+ }
109
+
110
+ export async function apply(ctx, config) {
111
+ requireCondition(ctx && typeof ctx.get === 'function' && typeof ctx.provide === 'function' && typeof ctx.effect === 'function',
112
+ 'DSH_BINDING_REQUIRED', 'apply() needs a Cordis plugin context; it cannot be called bare.');
113
+ const webServer = ctx.get('webServer'), facility = ctx.get('storageDomain'), connection = ctx.get('connection');
114
+ requireCondition(webServer && facility && connection, 'DSH_BINDING_REQUIRED', 'webServer, storageDomain and connection services are required.');
115
+ config ??= {};
116
+ const dataRoot = config.dataRoot ?? join(process.env.DSH_HOME ?? process.env.HOME ?? '.', 'data', 'hanamesh-apps');
117
+ requireCondition(typeof dataRoot === 'string', 'INVALID_ROOT', 'dataRoot is required.');
118
+ const parentOrigin = config.parentOrigin ?? `http://127.0.0.1:${webServer.port}`;
119
+ const domain = await facility.open(appHostDomainSpec);
120
+ let routerDomain,receiptsDomain,receipts,libraryDomain,library;
121
+ const store = new DshDomainSnapshotStore(domainBinding(domain));
122
+ const host = new AppHost({ store, dataRoot, parentOrigin, frameAncestors: config.frameAncestors ?? [],
123
+ ...(config.nodeBinary === undefined ? {} : { nodeBinary: config.nodeBinary }),
124
+ ...(config.leaseTtlMs === undefined ? {} : { leaseTtlMs: config.leaseTtlMs }),
125
+ ...(config.sweepIntervalMs === undefined ? {} : { sweepIntervalMs: config.sweepIntervalMs }) });
126
+ let initialized = false;
127
+ try {
128
+ for (const definition of config.applications ?? []) host.register(definition);
129
+ await host.init(); initialized = true;
130
+ const auth = { parentOrigin, ...browserAuthentication(connection) };
131
+ const handler = createHttpHandler(host, auth);
132
+ const disposers = ROUTES.map(path => webServer.register({ kind: 'exact', path, handler }));
133
+ let credentialService, llmService, oauthService, usageService;
134
+ ctx.inject(['credentials'], injected => { credentialService=injected.get('credentials');return()=>{credentialService=undefined;}; });
135
+ // rc.27: usage evidence goes through the usage plugin's record seat when (and only when) that plugin is loaded.
136
+ ctx.inject(['hanameshUsage'], injected => { usageService=injected.get('hanameshUsage');return()=>{usageService=undefined;}; });
137
+ // T6: hourly usage receipts live in app-host's own domain; the usage `use` event carries the hour's receipt.
138
+ receiptsDomain = await facility.open(receiptsDomainSpec);
139
+ receipts = createReceiptLedger({ domain:receiptsDomain });
140
+ const evidence = createUsageEvidence({ host, seat:()=>usageService, receipts, logger:ctx.logger });
141
+ ctx.effect(() => async () => { evidence.close(); await evidence.settle(); await receipts.close(); await receiptsDomain.close(); }, 'hanameshApps.usage');
142
+ ctx.inject(['llm'], injected => { llmService=injected.get('llm');return()=>{llmService=undefined;}; });
143
+ ctx.inject(['hanameshOAuth'], injected => { oauthService=injected.get('hanameshOAuth');return()=>{oauthService=undefined;}; });
144
+ const credentials = {
145
+ describe: async ref => credentialService ? await credentialService.describe(ref) : { configured:false,writable:false },
146
+ resolve: async ref => credentialService ? await credentialService.resolve(ref) : undefined,
147
+ describeRecord: async key => credentialService ? await credentialService.describeRecord(key) : { configured:false,writable:false },
148
+ };
149
+ routerDomain = await facility.open(routerDomainSpec);
150
+ const sources = createProviderSources({ credentials, apps:host, llm:()=>llmService,
151
+ webOrigin:`http://127.0.0.1:${webServer.port}`, codingOauth:config.router?.codingOauth });
152
+ const router = createRouter({ credentials, domain:routerDomain, apps:host, sources, oauth:()=>oauthService, receiptLedger:receipts,
153
+ onInject:({ appId, instanceId, routes }) => { receipts.inject({ appId, instanceId, routes }); } });
154
+ const detachResolver=host.setCredentialResolver(router.credentialResolver),detachObserver=host.subscribe(router.observe);
155
+ const routerHandler=createRouterHttpHandler(router,auth);
156
+ const routerDisposers=ROUTER_ROUTES.map(path=>webServer.register({kind:'exact',path,handler:routerHandler}));
157
+ libraryDomain=await facility.open(libraryDomainSpec);
158
+ // Explicit config wins; on plain DSH (no shell overlay) the profile, DSH bin and Node are inferred from this process.
159
+ const locations=await resolveLibraryLocations(config.library??{},{nodeBinary:config.nodeBinary});
160
+ const libraryConfig={...(config.library??{}),sources:locations.sources,profileDir:locations.profileDir,profileName:locations.profileName};
161
+ if(locations.inferred.length&&typeof ctx.logger?.info==='function')ctx.logger.info('hanamesh-app-host library: inferred %s',JSON.stringify(Object.fromEntries(
162
+ locations.inferred.map(key=>[key,key==='sources'?locations.sources.map(source=>source.manifestUrl):locations[key]]))));
163
+ let provisionApi,installer;
164
+ if(locations.profileDir&&locations.profileName&&locations.nodeBinary){
165
+ let dshBin=locations.dshBin;
166
+ if(!dshBin)try{dshBin=createRequire(join(locations.profileDir,'package.json')).resolve('@deepseek-ai/dsh/lib/bin.js');}catch{dshBin=undefined;}
167
+ if(dshBin){
168
+ provisionApi=await import('./provision/index.js');
169
+ installer=createLibraryInstaller({profileDir:locations.profileDir,profileName:locations.profileName,dataRoot,nodeBinary:locations.nodeBinary,
170
+ dshBin,registry:libraryConfig.registry,allowPrerelease:libraryConfig.allowPrerelease,provision:provisionApi.provision,remove:provisionApi.remove,
171
+ emit:event=>library?.emit(event)});
172
+ }
173
+ }
174
+ library=createLibraryService({domain:libraryDomain,host,config:libraryConfig,dataRoot,ledgerReader:provisionApi?.ledger??(async()=>({schema:1,items:{}})),installer});
175
+ await library.init();
176
+ // rc.27: app bundles register plain `app.json` (no package name); the installed scan knows which npm package ships
177
+ // which appId, and that package name is the usage-evidence `hanaRef`. Scan failure only costs evidence, never startup.
178
+ try { for (const row of await library.installed()) if (row.appId && row.packageName) { try { host.bindPackageName(row.appId,row.packageName); } catch {} } }
179
+ catch (error) { if (typeof ctx.logger?.debug === 'function') ctx.logger.debug('hanamesh-app-host usage: installed scan unavailable (%s)', error?.code ?? error?.message); }
180
+ const libraryHandler=createLibraryHttpHandler(library,auth);
181
+ const libraryDisposers=LIBRARY_ROUTES.map(path=>webServer.register({kind:'exact',path,handler:libraryHandler}));
182
+ ctx.effect(() => () => { for (const dispose of disposers.splice(0)) dispose(); }, 'hanameshApps.routes');
183
+ ctx.effect(() => async () => { for(const dispose of routerDisposers.splice(0))dispose();detachResolver();detachObserver();await routerDomain.close(); }, 'hanameshApps.router');
184
+ ctx.effect(() => async () => { for(const dispose of libraryDisposers.splice(0))dispose();await library.close(); }, 'hanameshApps.library');
185
+ ctx.provide('hanameshApps', host);
186
+ } catch (error) {
187
+ await library?.close().catch(()=>{});await libraryDomain?.close().catch(()=>{});await routerDomain?.close().catch(()=>{});await receipts?.close().catch(()=>{});await receiptsDomain?.close().catch(()=>{});
188
+ if (initialized) await host.dispose().catch(() => {}); else await store.close().catch(() => {});
189
+ throw error;
190
+ }
191
+ // Disposal order: routes go first (registered later, disposed earlier by Cordis), then the host
192
+ // stops every owned instance and closes the domain. An attached instance is never signalled.
193
+ ctx.effect(() => async () => { await host.dispose(); }, 'hanameshApps.dispose');
194
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,25 @@
1
+ export class AppHostError extends Error {
2
+ constructor(code, message, details = {}, status = 409) {
3
+ super(message); this.name = 'AppHostError'; this.code = code;
4
+ this.details = details; this.status = status;
5
+ }
6
+ }
7
+ export function requireCondition(ok, code, message, details = {}, status = 400) {
8
+ if (!ok) throw new AppHostError(code, message, details, status);
9
+ }
10
+ export const copy = value => structuredClone(value);
11
+ export class SerialQueue {
12
+ #tail = Promise.resolve();
13
+ run(fn) {
14
+ const result = this.#tail.then(fn);
15
+ this.#tail = result.catch(() => {});
16
+ return result;
17
+ }
18
+ async drained() { await this.#tail; }
19
+ }
20
+ export function bounded(promise, ms, label = 'Operation') {
21
+ let timer;
22
+ return Promise.race([promise, new Promise((_, reject) => {
23
+ timer = setTimeout(() => reject(new AppHostError('TIMEOUT', `${label} timed out.`, {}, 504)), ms);
24
+ })]).finally(() => clearTimeout(timer));
25
+ }