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

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 (120) hide show
  1. package/LICENSE +4 -7
  2. package/README.md +140 -13
  3. package/consistency.json +41 -6
  4. package/dist/catalog/fixtures/consumer.json +35 -0
  5. package/dist/catalog/fixtures/provider.json +107 -0
  6. package/dist/catalog/suite.d.ts +4 -0
  7. package/dist/catalog/suite.js +50 -0
  8. package/dist/client-ui.d.ts +21 -3
  9. package/dist/client-ui.js +961 -94
  10. package/dist/client.js +2 -2
  11. package/dist/control-origin.js +9 -0
  12. package/dist/descriptor.js +6 -0
  13. package/dist/dsh-compat.js +6 -0
  14. package/dist/dsh.d.ts +21 -4
  15. package/dist/dsh.js +149 -58
  16. package/dist/gateway.js +140 -11
  17. package/dist/git-process.js +64 -0
  18. package/dist/git-worker.js +50 -0
  19. package/dist/index.d.ts +9 -7
  20. package/dist/install-target/fixtures/catalog.json +58 -0
  21. package/dist/install-target/fixtures/consumer-cases.json +148 -0
  22. package/dist/install-target/fixtures/install-failure-cases.json +58 -0
  23. package/dist/install-target/fixtures/provider-cases.json +618 -0
  24. package/dist/install-target/fixtures/reference-consumer.js +37 -0
  25. package/dist/install-target/fixtures/source-profile.js +123 -0
  26. package/dist/install-target/harness.js +43 -0
  27. package/dist/install-target/run.js +28 -0
  28. package/dist/install-target/suite.d.ts +19 -0
  29. package/dist/install-target/suite.js +328 -0
  30. package/dist/installation-status.d.ts +21 -0
  31. package/dist/installation-status.js +2 -0
  32. package/dist/library/catalog.js +163 -19
  33. package/dist/library/client-identity.js +31 -0
  34. package/dist/library/desktop-cli.js +7 -0
  35. package/dist/library/domain.js +3 -1
  36. package/dist/library/handoff.js +246 -0
  37. package/dist/library/install.js +138 -32
  38. package/dist/library/installation-cli.js +48 -0
  39. package/dist/library/installation-command-worker.js +27 -0
  40. package/dist/library/installation-status-http.js +26 -0
  41. package/dist/library/installation-status.js +91 -0
  42. package/dist/library/installation-worker.js +44 -0
  43. package/dist/library/locate.js +70 -9
  44. package/dist/library/node-binding.js +11 -0
  45. package/dist/library/recovery.js +66 -0
  46. package/dist/library/routes.js +90 -14
  47. package/dist/library/service.js +123 -27
  48. package/dist/library/suite.js +130 -0
  49. package/dist/manager.js +24 -8
  50. package/dist/node-runtime/domain.js +6 -0
  51. package/dist/node-runtime/releases.json +59 -0
  52. package/dist/node-runtime/service.js +101 -0
  53. package/dist/owned-probe-diagnostic.js +53 -0
  54. package/dist/router/receipts.js +8 -11
  55. package/dist/router/routes.js +7 -7
  56. package/dist/routes.js +9 -8
  57. package/dist/runtime.js +11 -1
  58. package/dist/usage-evidence.js +18 -86
  59. package/dist/workspace-origin.js +17 -0
  60. package/docs/APP_PACKAGE.md +4 -0
  61. package/docs/CONTRACT.md +75 -11
  62. package/docs/DSH_COMPATIBILITY.md +94 -0
  63. package/docs/DSH_INTEGRATION.md +41 -0
  64. package/docs/INSTALLATION_STATUS.md +96 -0
  65. package/docs/INSTALL_TARGET.md +130 -0
  66. package/docs/LIBRARY.md +44 -3
  67. package/docs/LICENSES.json +189 -61
  68. package/docs/PROVENANCE.json +53 -12
  69. package/docs/SECURITY.md +31 -0
  70. package/node_modules/@hanamesh/lib-provision/LICENSE +15 -0
  71. package/node_modules/@hanamesh/lib-provision/README.md +134 -0
  72. package/node_modules/@hanamesh/lib-provision/THIRD_PARTY_NOTICES.md +17 -0
  73. package/node_modules/@hanamesh/lib-provision/bin/hanamesh-provision.js +13 -0
  74. package/node_modules/@hanamesh/lib-provision/consistency.json +20 -0
  75. package/node_modules/@hanamesh/lib-provision/contract/CHANGELOG.md +5 -0
  76. package/node_modules/@hanamesh/lib-provision/contract/conformance.mjs +80 -0
  77. package/node_modules/@hanamesh/lib-provision/contract/fixtures/consumer.json +9 -0
  78. package/node_modules/@hanamesh/lib-provision/contract/fixtures/provider.json +11 -0
  79. package/node_modules/@hanamesh/lib-provision/contract/lib-provision.schema.json +47 -0
  80. package/node_modules/@hanamesh/lib-provision/package.json +50 -0
  81. package/package.json +81 -33
  82. package/schemas/catalog-provider-page.schema.json +884 -7
  83. package/schemas/catalog-source.schema.json +9 -7
  84. package/schemas/install-target.schema.json +298 -0
  85. package/dist/provision/LICENSE +0 -6
  86. package/dist/provision/THIRD_PARTY_NOTICES.md +0 -13
  87. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/archive.d.ts +0 -0
  88. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/archive.js +0 -0
  89. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/check.d.ts +0 -0
  90. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/check.js +0 -0
  91. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/checkpoints.d.ts +0 -0
  92. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/checkpoints.js +0 -0
  93. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/cli.d.ts +0 -0
  94. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/cli.js +0 -0
  95. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/download.d.ts +0 -0
  96. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/download.js +0 -0
  97. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/errors.d.ts +0 -0
  98. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/errors.js +0 -0
  99. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/extract.d.ts +0 -0
  100. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/extract.js +0 -0
  101. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/fsutil.d.ts +0 -0
  102. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/fsutil.js +0 -0
  103. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/index.d.ts +0 -0
  104. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/index.js +0 -0
  105. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/ledger.d.ts +0 -0
  106. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/ledger.js +0 -0
  107. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/manifest.d.ts +0 -0
  108. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/manifest.js +0 -0
  109. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/paths.d.ts +0 -0
  110. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/paths.js +0 -0
  111. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/platform.d.ts +0 -0
  112. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/platform.js +0 -0
  113. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/provision.d.ts +0 -0
  114. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/provision.js +0 -0
  115. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/tar.d.ts +0 -0
  116. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/tar.js +0 -0
  117. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/types.d.ts +0 -0
  118. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/types.js +0 -0
  119. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/zip.d.ts +0 -0
  120. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/zip.js +0 -0
@@ -0,0 +1,21 @@
1
+ /** Advertised metadata only. Discovery performs no installation read. */
2
+ export const INSTALLATION_STATUS_CAPABILITY:Readonly<{contract:'hanamesh.installation-status';contractVersion:'1';path:'/hanamesh/library/installation-status'}>;
3
+ export interface InstallationReaderConfig {
4
+ /** Absolute official executable and explicitly selected user/config directories. Never supplied by a website. */
5
+ executable:string;home:string;configDir:string;
6
+ /** Identity and exact HTTPS marketplace URL from the registered AgentPluginDTO. */
7
+ pluginName:string;marketplace:string;marketplaceUrl:string;
8
+ /** Required positive bounded operation budgets. No product default is inferred. */
9
+ timeoutMs:number;maxOutputBytes:number;
10
+ }
11
+ export interface InstallationStatusRequest {contractVersion:'1';consent:'read-installations'}
12
+ export interface InstallationObservation {
13
+ channel:'hanamesh'|'dsh'|'claude'|'codex';
14
+ scope:{id:'current-runtime'|'other-runtime'|'agent-cli'|'desktop-app';kind:'responding-runtime'|'unobserved-runtime'|'official-user-profile'|'desktop-app';label:string};
15
+ status:'installed'|'not-installed'|'incomplete'|'unknown';version:string|null;versionKind:'core'|'agent-distribution';
16
+ components:readonly {name:string;version:string|null;active?:boolean;status?:string}[];
17
+ identity:{pluginName:string;marketplace:string}|null;
18
+ reason:{code:string;message:string;action:string}|null;
19
+ blocked?:readonly {code:string;moduleName?:string}[];
20
+ }
21
+ export interface InstallationStatus {schemaVersion:1;client:'hanamesh'|'dsh';checkedAt:string;observations:readonly InstallationObservation[];traceId?:string}
@@ -0,0 +1,2 @@
1
+ /** Public read-only capability. A probe advertises this metadata without running readers. */
2
+ export const INSTALLATION_STATUS_CAPABILITY=Object.freeze({contract:'hanamesh.installation-status',contractVersion:'1',path:'/hanamesh/library/installation-status'});
@@ -1,45 +1,157 @@
1
+ import semver from 'semver';
1
2
  import { readFile } from 'node:fs/promises';
2
3
  import { AppHostError, requireCondition } from '../errors.js';
3
4
 
5
+ // Catalog schema versions are SemVer; install-target protocol '1' is a separate grammar.
6
+ const catalogVersion=value=>typeof value==='string'&&/^1\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$(?![\s\S])/u.test(value);
7
+
4
8
  const MAX_BYTES=2*1024*1024;
9
+ const HANAMESH_SCHEMA='hanamesh.catalog.v1';
10
+ const MAX_SOURCE_LINKS=500;
5
11
  const plain=value=>typeof value==='string'&&value.length>0&&!/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test(value);
6
12
  const own=(value,keys)=>value&&typeof value==='object'&&!Array.isArray(value)&&Object.keys(value).every(key=>keys.includes(key));
13
+ // WHATWG URL canonicalizes hostnames (including IPv6); no DNS lookup or origin aliasing.
14
+ const catalogOrigin=url=>(url.protocol==='https:'&&!url.port)||
15
+ (url.protocol==='http:'&&['127.0.0.1','[::1]','localhost'].includes(url.hostname));
7
16
 
8
17
  export function validateManifestUrl(value){
9
18
  let url;try{url=new URL(value);}catch{throw new AppHostError('INVALID_CATALOG_URL','Catalog manifest URL is invalid.');}
10
- requireCondition(url.protocol==='https:'&&!url.port&&!url.username&&!url.password&&!url.search&&!url.hash,
11
- 'INVALID_CATALOG_URL','Catalog manifests require standard-port HTTPS without credentials, query, or fragment.');
19
+ const viewQuery=!url.search||(url.searchParams.size===1&&url.searchParams.get('view')==='hanamesh');
20
+ requireCondition(catalogOrigin(url)&&!url.username&&!url.password&&viewQuery&&!url.hash,
21
+ 'INVALID_CATALOG_URL','Catalog manifests require standard-port HTTPS or loopback HTTP (127.0.0.1, ::1, localhost), with only an optional view=hanamesh query.');
12
22
  return url;
13
23
  }
14
24
 
15
25
  export function validateCatalogManifest(value){
16
- requireCondition(own(value,['manifestVersion','providerId','name','description','homepage','attribution','transport','query'])&&
17
- value.manifestVersion==='1.0.0'&&/^[a-z0-9]+(?:[.-][a-z0-9]+)+$/.test(value.providerId)&&plain(value.name)&&
26
+ let endpoint;try{endpoint=validateManifestUrl(value?.transport?.endpoint);}catch{throw new AppHostError('INVALID_CATALOG_MANIFEST','Catalog endpoint URL is invalid.');}
27
+ const hana=endpoint.searchParams.get('view')==='hanamesh';
28
+ requireCondition(own(value,['manifestVersion','providerId','name','description','homepage','attribution','transport','query',...(hana?['limits']:[])])&&
29
+ catalogVersion(value.manifestVersion)&&/^[a-z0-9]+(?:[.-][a-z0-9]+)+$/.test(value.providerId)&&plain(value.name)&&
18
30
  own(value.attribution,['name','url','notice'])&&plain(value.attribution.name)&&
19
31
  own(value.transport,['kind','endpoint','method'])&&value.transport.kind==='https-json'&&value.transport.method==='GET'&&
20
32
  own(value.query,['supported','defaultLimit','maxLimit','sorts'])&&Array.isArray(value.query.supported)&&Array.isArray(value.query.sorts)&&
21
33
  Number.isInteger(value.query.defaultLimit)&&value.query.defaultLimit>=1&&value.query.defaultLimit<=200&&
22
34
  Number.isInteger(value.query.maxLimit)&&value.query.maxLimit>=value.query.defaultLimit&&value.query.maxLimit<=200,
23
- 'INVALID_CATALOG_MANIFEST','Catalog source manifest does not match schema 1.0.0.');
24
- validateManifestUrl(value.attribution.url);
25
- const endpoint=validateManifestUrl(value.transport.endpoint);
35
+ 'INVALID_CATALOG_MANIFEST','Catalog source manifest does not match catalog schema major 1.');
36
+ if(hana){
37
+ requireCondition(displayUrl(value.attribution.url,true)&&
38
+ own(value.limits,['maxBodyBytes','maxSourceLinks'])&&
39
+ Number.isSafeInteger(value.limits.maxBodyBytes)&&value.limits.maxBodyBytes>0&&value.limits.maxBodyBytes<=MAX_BYTES&&
40
+ Number.isSafeInteger(value.limits.maxSourceLinks)&&value.limits.maxSourceLinks>=0&&value.limits.maxSourceLinks<=MAX_SOURCE_LINKS&&
41
+ value.query.supported.every(key=>['q','category','cursor','limit'].includes(key))&&value.query.sorts.length===0,
42
+ 'INVALID_CATALOG_MANIFEST','HanaMesh manifest limits or query declaration is invalid.');
43
+ }else{
44
+ const attribution=validateManifestUrl(value.attribution.url);
45
+ requireCondition(!attribution.search,'INVALID_CATALOG_MANIFEST','Default catalog attribution must not contain a query.');
46
+ }
26
47
  requireCondition(endpoint.pathname.endsWith('/v1/plugins'),'INVALID_CATALOG_MANIFEST','Catalog endpoint must end in /v1/plugins.');
27
48
  return structuredClone(value);
28
49
  }
29
50
 
51
+ const textWithin=(value,max,nonempty=false)=>typeof value==='string'&&value.length<=max&&(!nonempty||value.length>0)&&
52
+ !/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test(value);
53
+ const npmName=value=>textWithin(value,214,true)&&/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(value);
54
+ function displayUrl(value,catalogSource=false){
55
+ if(!textWithin(value,2048,true))return false;
56
+ try{const url=new URL(value);return (catalogSource?catalogOrigin(url):url.protocol==='https:'&&!url.port)&&!url.username&&!url.password;}catch{return false;}
57
+ }
58
+ function timestamp(value,utc=false){
59
+ if(typeof value!=='string')return false;
60
+ const match=/^(\d{4})-(\d{2})-(\d{2})T([01]\d|2[0-3]):([0-5]\d):([0-5]\d)(?:\.\d+)?(Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/.exec(value);
61
+ if(!match||(utc&&match[7]!=='Z')||!Number.isFinite(Date.parse(value)))return false;
62
+ const date=new Date(`${match[1]}-${match[2]}-${match[3]}T00:00:00Z`);
63
+ return Number.isFinite(date.getTime())&&date.toISOString().slice(0,10)===value.slice(0,10);
64
+ }
65
+ // Display-only C-INDEXER-CATALOG SuitePresentationDTO; never an install/upgrade decision.
66
+ const suiteIdentities={
67
+ core:{names:['@hanamesh/core','hanamesh-core'],repo:'yzsnstotz/hanamesh-core'},
68
+ usage:{names:['@hanamesh/usage','hanamesh-usage'],repo:'yzsnstotz/hanamesh-usage'},
69
+ 'app-host':{names:['@hanamesh/dsh-app-host'],repo:'yzsnstotz/hanamesh-plugin-app-host'},
70
+ brand:{names:['@hanamesh/brand','hanamesh-brand'],repo:'yzsnstotz/hanamesh-brand'},
71
+ };
72
+ const suiteComponent=value=>Object.hasOwn(suiteIdentities,value);
73
+ const exactVersion=value=>textWithin(value,128,true)&&!value.startsWith('v')&&Boolean(semver.valid(value))&&value.trim()===value;
74
+ const packageFact=fact=>fact&&suiteComponent(fact.component)&&suiteIdentities[fact.component].names.includes(fact.npmPackageName)&&suiteIdentities[fact.component].repo===fact.githubRepo;
75
+ const distinctComponents=(facts,count)=>Array.isArray(facts)&&facts.length===count&&new Set(facts.map(fact=>fact?.component)).size===count;
76
+ function suitePresentation(value,item){
77
+ if(!(own(value,['schemaVersion','id','role','component','canonicalHanaId','installable','installTargets','components','distributionVersion','agentPlugins'])&&
78
+ value.schemaVersion===1&&value.id==='hanamesh'&&['suite','component','client-only'].includes(value.role)&&suiteComponent(value.component)&&
79
+ (value.canonicalHanaId===null||plain(value.canonicalHanaId))&&typeof value.installable==='boolean'))return false;
80
+ const canonical=value.role==='suite'&&value.component==='usage'&&value.installable&&value.canonicalHanaId===item.id;
81
+ if(value.role!==(value.component==='usage'?'suite':value.component==='brand'?'client-only':'component')||value.installable!==canonical)return false;
82
+ if(value.distributionVersion!==undefined&&(!canonical||!exactVersion(value.distributionVersion)))return false;
83
+ if(value.components!==undefined&&(!canonical||!distinctComponents(value.components,4)||!value.components.every(fact=>
84
+ own(fact,['component','npmPackageName','githubRepo','version'])&&packageFact(fact)&&exactVersion(fact.version))))return false;
85
+ if(value.installTargets!==undefined){
86
+ if(!canonical||!distinctComponents(value.installTargets,3)||!value.installTargets.every(target=>{
87
+ if(!(own(target,['component','npmPackageName','githubRepo','version','source'])&&target.component!=='brand'&&packageFact(target)&&
88
+ (target.version===undefined||exactVersion(target.version))))return false;
89
+ const fact=value.components?.find(fact=>fact.component===target.component),source=target.source;
90
+ if(fact&&(fact.npmPackageName!==target.npmPackageName||fact.githubRepo!==target.githubRepo||
91
+ (target.version!==undefined&&fact.version!==target.version)))return false;
92
+ if(source?.source==='npm')return own(source,['source','name','version'])&&source.name===target.npmPackageName&&
93
+ exactVersion(source.version)&&source.version===target.version&&fact?.version===target.version;
94
+ // Approved standard distribution retains immutable historical Git DTOs, without choosing a fallback source.
95
+ return own(source,['source','repo','ref'])&&source.source==='github'&&source.repo===target.githubRepo&&
96
+ typeof source.ref==='string'&&/^[0-9a-f]{40}$/i.test(source.ref);
97
+ }))return false;
98
+ }
99
+ if(value.agentPlugins!==undefined){
100
+ const agents=value.agentPlugins;
101
+ if(!canonical||!Array.isArray(agents)||agents.length<1||agents.length>2||new Set(agents.map(agent=>agent?.host)).size!==agents.length||
102
+ !agents.every(agent=>own(agent,['host','pluginName','pluginId','marketplaceUrl','version'])&&['claude','codex'].includes(agent.host)&&
103
+ agent.pluginName==='hanamesh'&&agent.pluginId==='hanamesh@hanamesh'&&agent.marketplaceUrl==='https://github.com/yzsnstotz/hanamesh-plugins'&&
104
+ exactVersion(agent.version)&&agent.version===value.distributionVersion))return false;
105
+ }
106
+ return true;
107
+ }
108
+
109
+ function validateHanaItem(item,maxSourceLinks){
110
+ const optionalText={description:5000,latestVersion:64,license:80};
111
+ const source=item?.source;
112
+ requireCondition(own(item,['id','itemId','packageName','name','displayName','summary','catalogKind','source','description','homepage','latestVersion','license','categories','keywords','repository','package','publisher','updatedAt','official','suite'])&&
113
+ (item.official===undefined||typeof item.official==='boolean')&&(item.suite===undefined||suitePresentation(item.suite,item))&&
114
+ plain(item.id)&&item.id===item.itemId&&
115
+ textWithin(item.name,160,true)&&textWithin(item.displayName,120,true)&&textWithin(item.summary,1000)&&
116
+ ['application','plugin','entry'].includes(item.catalogKind)&&
117
+ (item.packageName===null?item.package===undefined:
118
+ npmName(item.packageName)&&own(item.package,['registry','name'])&&item.package.registry==='npm'&&item.package.name===item.packageName)&&
119
+ Object.entries(optionalText).every(([key,max])=>item[key]===undefined||textWithin(item[key],max))&&
120
+ (item.homepage===undefined||displayUrl(item.homepage))&&
121
+ (item.updatedAt===undefined||timestamp(item.updatedAt))&&
122
+ (item.categories===undefined||(Array.isArray(item.categories)&&item.categories.length<=32&&item.categories.every(value=>textWithin(value,64,true)&&/^[a-z0-9][a-z0-9._:-]{0,63}$/.test(value))))&&
123
+ (item.keywords===undefined||(Array.isArray(item.keywords)&&item.keywords.length<=64&&item.keywords.every(value=>textWithin(value,64))))&&
124
+ (item.repository===undefined||(own(item.repository,['url','subdirectory'])&&displayUrl(item.repository.url)&&new URL(item.repository.url).hostname==='github.com'&&
125
+ (item.repository.subdirectory===undefined||(textWithin(item.repository.subdirectory,240,true)&&!/^\/|^[a-z]:/i.test(item.repository.subdirectory)&&!item.repository.subdirectory.includes('\\')&&!item.repository.subdirectory.split('/').includes('..')))))&&
126
+ (item.publisher===undefined||(own(item.publisher,['name','url'])&&textWithin(item.publisher.name,120,true)&&(item.publisher.url===undefined||displayUrl(item.publisher.url))))&&
127
+ own(source,['sourceId','sourceRef','sourceLinks'])&&textWithin(source.sourceId,120,true)&&textWithin(source.sourceRef,512,true)&&
128
+ Array.isArray(source.sourceLinks)&&source.sourceLinks.length<=maxSourceLinks&&source.sourceLinks.every(link=>
129
+ own(link,['sourceId','sourceRef','sourceUrl','firstSeenAt','lastSeenAt'])&&textWithin(link.sourceId,120,true)&&textWithin(link.sourceRef,512,true)&&
130
+ displayUrl(link.sourceUrl)&&timestamp(link.firstSeenAt)&&timestamp(link.lastSeenAt)),
131
+ 'INVALID_CATALOG_PAGE','HanaMesh catalog item does not match its public schema.');
132
+ }
133
+
30
134
  function validateItem(item){
31
135
  // Key set = schemas/catalog-provider-page.schema.json `item` (incl. optional `updatedAt`; the production source emits it — 2026-09-20).
32
136
  requireCondition(own(item,['id','name','displayName','summary','description','homepage','latestVersion','license','categories','keywords','capabilities','compatibility','repository','installSource','package','publisher','media','updatedAt'])&&(item.updatedAt===undefined||(typeof item.updatedAt==='string'&&Number.isFinite(Date.parse(item.updatedAt))))&&
33
137
  plain(item.id)&&item.id.length<=160&&plain(item.name)&&plain(item.displayName)&&plain(item.summary)&&
34
138
  (item.categories===undefined||(Array.isArray(item.categories)&&new Set(item.categories).size===item.categories.length&&item.categories.every(x=>/^[a-z0-9][a-z0-9._:-]{0,63}$/.test(x))))&&
35
139
  (item.package===undefined||(own(item.package,['registry','name'])&&item.package.registry==='npm'&&/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(item.package.name))),
36
- 'INVALID_CATALOG_PAGE','Catalog item does not match schema 1.0.0.');
140
+ 'INVALID_CATALOG_PAGE','Catalog item does not match catalog schema major 1.');
37
141
  }
38
142
 
39
- export function validateProviderPage(value){
40
- requireCondition(own(value,['schemaVersion','generatedAt','revision','items','page'])&&value.schemaVersion==='1.0.0'&&
143
+ export function validateProviderPage(value,{maxItems=200,maxSourceLinks=MAX_SOURCE_LINKS}={}){
144
+ if(value?.schemaVersion===HANAMESH_SCHEMA){
145
+ requireCondition(own(value,['schemaVersion','generatedAt','items','page'])&&
146
+ (value.generatedAt===undefined||timestamp(value.generatedAt,true))&&Array.isArray(value.items)&&value.items.length<=maxItems&&own(value.page,['nextCursor','total'])&&
147
+ (Object.keys(value.page).length===0||(Object.keys(value.page).length===2&&typeof value.page.nextCursor==='string'&&value.page.nextCursor.length>0&&Number.isSafeInteger(value.page.total)&&value.page.total>=0)),
148
+ 'INVALID_CATALOG_PAGE','HanaMesh catalog page does not match its public schema.');
149
+ for(const item of value.items)validateHanaItem(item,maxSourceLinks);
150
+ return structuredClone(value);
151
+ }
152
+ requireCondition(own(value,['schemaVersion','generatedAt','revision','items','page'])&&catalogVersion(value.schemaVersion)&&
41
153
  Array.isArray(value.items)&&value.items.length<=200&&own(value.page,['nextCursor','total']),
42
- 'INVALID_CATALOG_PAGE','Catalog provider page does not match schema 1.0.0.');
154
+ 'INVALID_CATALOG_PAGE','Catalog provider page does not match catalog schema major 1.');
43
155
  for(const item of value.items)validateItem(item);
44
156
  return structuredClone(value);
45
157
  }
@@ -63,16 +175,26 @@ export function compareVersions(a,b){
63
175
  /** `latestVersion` of the catalog entry is strictly newer than the installed version. */
64
176
  export function upgradeAvailable(latestVersion,installedVersion){return compareVersions(latestVersion,installedVersion)>0;}
65
177
 
66
- async function responseJson(response,label){
67
- requireCondition(response.ok,'CATALOG_HTTP_ERROR',`${label} returned HTTP ${response.status}.`,{status:response.status},502);
178
+ async function responseJson(response,label,maxBytes=MAX_BYTES,providerErrors=false){
179
+ if(!providerErrors)requireCondition(response.ok,'CATALOG_HTTP_ERROR',`${label} returned HTTP ${response.status}.`,{status:response.status},502);
68
180
  const contentType=response.headers.get('content-type')?.split(';')[0].trim().toLowerCase()??'';
69
181
  requireCondition(/^application\/(?:json|.+\+json)$/.test(contentType)&&!response.headers.get('content-encoding'),
70
182
  'CATALOG_RESPONSE_DENIED',`${label} must be unencoded JSON.`,{},502);
71
183
  const declared=Number(response.headers.get('content-length')??0);
72
- requireCondition(!declared||declared<=MAX_BYTES,'CATALOG_RESPONSE_TOO_LARGE',`${label} exceeds 2 MiB.`,{},502);
184
+ requireCondition(!declared||declared<=maxBytes,'CATALOG_RESPONSE_TOO_LARGE',`${label} exceeds the declared body limit.`,{},502);
73
185
  const bytes=new Uint8Array(await response.arrayBuffer());
74
- requireCondition(bytes.byteLength<=MAX_BYTES,'CATALOG_RESPONSE_TOO_LARGE',`${label} exceeds 2 MiB.`,{},502);
75
- try{return JSON.parse(new TextDecoder().decode(bytes));}catch{throw new AppHostError('INVALID_CATALOG_JSON',`${label} returned malformed JSON.`,{},502);}
186
+ requireCondition(bytes.byteLength<=maxBytes,'CATALOG_RESPONSE_TOO_LARGE',`${label} exceeds the declared body limit.`,{},502);
187
+ let value;try{value=JSON.parse(new TextDecoder().decode(bytes));}catch{throw new AppHostError('INVALID_CATALOG_JSON',`${label} returned malformed JSON.`,{},502);}
188
+ if(!response.ok){
189
+ const codes={INVALID_REQUEST:400,INVALID_CATALOG_ORIGIN:400,CATALOG_CHANGED:409,UPSTREAM_UNAVAILABLE:503,CATALOG_RESPONSE_TOO_LARGE:500};
190
+ const code=value?.error?.code;
191
+ if(typeof code==='string'&&Object.hasOwn(codes,code)&&codes[code]===response.status){
192
+ const missing=Array.isArray(value.error.missing)?value.error.missing.filter(name=>['CATALOG_DB','HM_SERVER_ORIGIN'].includes(name)):[];
193
+ throw new AppHostError(code,`${label} was refused by the catalog provider.`,missing.length?{missing:[...new Set(missing)]}:{},response.status);
194
+ }
195
+ throw new AppHostError('CATALOG_HTTP_ERROR',`${label} returned HTTP ${response.status}.`,{status:response.status},502);
196
+ }
197
+ return value;
76
198
  }
77
199
 
78
200
  async function sameOriginFetch(start,fetchImpl,label){
@@ -89,6 +211,14 @@ async function sameOriginFetch(start,fetchImpl,label){
89
211
  }
90
212
  }
91
213
 
214
+ function hanaQueryCategories(query,category,cursor){
215
+ const categories=Array.isArray(category)?category:category?[category]:[];
216
+ requireCondition(typeof query==='string'&&query.length<=200&&categories.length<=20&&categories.every(value=>typeof value==='string'&&/^[a-z0-9][a-z0-9._:-]{0,63}$/.test(value))&&
217
+ (cursor===undefined||(typeof cursor==='string'&&cursor.length>0)),
218
+ 'INVALID_REQUEST','Catalog query does not match the HanaMesh view contract.');
219
+ return categories;
220
+ }
221
+
92
222
  export async function loadCatalog({fixture,sources=[],fetchImpl=globalThis.fetch,query='',category='',cursor}={}){
93
223
  if(fixture){
94
224
  requireCondition(typeof fixture==='string','INVALID_FIXTURE','Catalog fixture path is required.');
@@ -98,11 +228,25 @@ export async function loadCatalog({fixture,sources=[],fetchImpl=globalThis.fetch
98
228
  const enabled=sources.filter(source=>source?.enabled!==false);
99
229
  requireCondition(enabled.length===1,'CATALOG_SOURCE_REQUIRED','Exactly one catalog source must be enabled.');
100
230
  const manifestUrl=validateManifestUrl(enabled[0].manifestUrl);
231
+ // Validate caller input before any provider request, including discovery.
232
+ const queryCategories=manifestUrl.searchParams.get('view')==='hanamesh'?hanaQueryCategories(query,category,cursor):undefined;
101
233
  const manifestResponse=await sameOriginFetch(manifestUrl,fetchImpl,'Catalog manifest');
102
- const manifest=validateCatalogManifest(await responseJson(manifestResponse.response,'Catalog manifest'));
234
+ const manifest=validateCatalogManifest(await responseJson(manifestResponse.response,'Catalog manifest',MAX_BYTES,manifestUrl.searchParams.get('view')==='hanamesh'));
103
235
  const endpoint=validateManifestUrl(manifest.transport.endpoint);
104
236
  requireCondition(endpoint.origin===manifestUrl.origin,'INVALID_CATALOG_MANIFEST','Catalog endpoint must share the manifest origin.');
105
- if(query)endpoint.searchParams.set('q',String(query).slice(0,200));if(category)endpoint.searchParams.set('category',String(category).slice(0,64));if(cursor)endpoint.searchParams.set('cursor',String(cursor).slice(0,2048));endpoint.searchParams.set('limit','50');
237
+ requireCondition(endpoint.searchParams.get('view')===manifestUrl.searchParams.get('view'),'INVALID_CATALOG_MANIFEST','Catalog endpoint must retain the selected view.');
238
+ const hana=endpoint.searchParams.get('view')==='hanamesh';
239
+ if(hana){
240
+ const categories=queryCategories;
241
+ if(query)endpoint.searchParams.set('q',query);
242
+ for(const value of categories)endpoint.searchParams.append('category',value);
243
+ if(cursor!==undefined)endpoint.searchParams.set('cursor',cursor);
244
+ endpoint.searchParams.set('limit',String(manifest.query.defaultLimit));
245
+ }else{
246
+ if(query)endpoint.searchParams.set('q',String(query).slice(0,200));if(category)endpoint.searchParams.set('category',String(category).slice(0,64));if(cursor)endpoint.searchParams.set('cursor',String(cursor).slice(0,2048));endpoint.searchParams.set('limit','50');
247
+ }
106
248
  const pageResponse=await sameOriginFetch(endpoint,fetchImpl,'Catalog page');
107
- return{source:{kind:'remote',manifestUrl:manifestUrl.href,manifest},...validateProviderPage(await responseJson(pageResponse.response,'Catalog page'))};
249
+ const page=await responseJson(pageResponse.response,'Catalog page',hana?manifest.limits.maxBodyBytes:MAX_BYTES,hana);
250
+ requireCondition(hana?page?.schemaVersion===HANAMESH_SCHEMA:catalogVersion(page?.schemaVersion),'INVALID_CATALOG_PAGE','Catalog page must match the selected view.');
251
+ return{source:{kind:'remote',manifestUrl:manifestUrl.href,manifest},...validateProviderPage(page,hana?{maxItems:manifest.query.maxLimit,maxSourceLinks:manifest.limits.maxSourceLinks}:{})};
108
252
  }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Which client this host runs in, for the install-target probe: 'hanamesh' (the HanaMesh desktop client) or 'dsh'
3
+ * (plain DSH, desktop or CLI). The only fact used is the bundle identifier of the macOS application that contains the
4
+ * desktop runtime — C-DESKTOP-HOST fixes it to `com.hanamesh.desktop` (DSH_DESKTOP_APP_ID). `evidence` says what was
5
+ * seen, so a wrong answer can be traced instead of guessed at.
6
+ */
7
+ import { execFile } from 'node:child_process';
8
+ import { promisify } from 'node:util';
9
+ import { join, sep } from 'node:path';
10
+
11
+ export const HANAMESH_BUNDLE_ID = 'com.hanamesh.desktop';
12
+
13
+ /** `<X>.app` directory that contains `path`, or undefined. */
14
+ export function containingApp(path) {
15
+ if (typeof path !== 'string') return undefined;
16
+ const parts = path.split(sep), index = parts.findLastIndex(part => part.endsWith('.app'));
17
+ return index > 0 ? parts.slice(0, index + 1).join(sep) : undefined;
18
+ }
19
+
20
+ export async function detectClient({ desktop, platform = process.platform, readBundleId } = {}) {
21
+ if (!desktop) return { client: 'dsh', evidence: 'not-desktop' };
22
+ const app = containingApp(desktop.desktopCli?.runtimeDir ?? desktop.runtimeDir);
23
+ if (!app) return { client: 'dsh', evidence: 'desktop-without-app-bundle' };
24
+ if (platform !== 'darwin' && !readBundleId) return { client: 'dsh', evidence: `app-bundle-unreadable-on-${platform}` };
25
+ let id;
26
+ try {
27
+ id = readBundleId ? await readBundleId(app) : (await promisify(execFile)('/usr/bin/plutil',
28
+ ['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', join(app, 'Contents', 'Info.plist')])).stdout.trim();
29
+ } catch (error) { return { client: 'dsh', evidence: `bundle-id-unreadable:${error.code ?? error.message}` }; }
30
+ return { client: id === HANAMESH_BUNDLE_ID ? 'hanamesh' : 'dsh', evidence: `bundle-id:${id}` };
31
+ }
@@ -0,0 +1,7 @@
1
+ /** Invoke the inspected upstream Desktop CLI export; the ordinary CLI refuses the reserved profile. */
2
+ import {pathToFileURL} from 'node:url';
3
+ const [entry,runtimeDir,supportDir,...args]=process.argv.slice(2);
4
+ const {runDesktopCli}=await import(pathToFileURL(entry).href);
5
+ if(typeof runDesktopCli!=='function')throw new Error('DSH_BINDING_REQUIRED: Desktop runDesktopCli export is unavailable.');
6
+ process.argv=[process.execPath,entry,...args];
7
+ await runDesktopCli(runtimeDir,supportDir);
@@ -3,6 +3,8 @@ import { z as zod } from 'zod';
3
3
 
4
4
  const source=zod.object({manifestUrl:zod.string(),enabled:zod.boolean()});
5
5
  export const libraryDomainSpec=defineDomain({name:'hanamesh_library',version:1,layout:'single',tables:{},global:{
6
- schema:zod.object({schema:zod.literal(1),revision:zod.number().int().nonnegative(),sources:zod.array(source)}),
6
+ schema:zod.object({schema:zod.literal(1),revision:zod.number().int().nonnegative(),sources:zod.array(source),suiteSource:zod.object({mode:zod.enum(['configured','core'])}).strict().optional(),
7
+ /** Website handoff settings saved from the library settings page; absent → plugin config / proposed defaults. */
8
+ handoff:zod.object({enabled:zod.boolean(),ports:zod.array(zod.number().int()),allowedOrigins:zod.array(zod.string()),ttlMs:zod.number().int(),pollMs:zod.number().int().min(1).max(2_147_483_647).optional()}).optional()}),
7
9
  initial:{schema:1,revision:0,sources:[]},
8
10
  }});
@@ -0,0 +1,246 @@
1
+ import { installationStatusCors, serveInstallationStatus } from './installation-status-http.js';
2
+ /**
3
+ * Website → client install handoff (C-MARKET, 2026-10-15 E/F). Upstream Desktop listens on an OS-assigned port that
4
+ * nothing records, and a browser tab without the launch token gets 401, so a website can neither find nor enter the
5
+ * DSH web server. The library therefore opens its own loopback listener on the first free port of a named list and
6
+ * answers exactly three routes there:
7
+ *
8
+ * GET /hanamesh/library/install-target probe (client, ready, need) for allowed website origins only
9
+ * POST /hanamesh/library/handoff queue a target from an allowed website origin; never installs
10
+ * GET /hanamesh/library/handoff?id= that handoff's state for the same origin
11
+ *
12
+ * A queued handoff is shown by the library UI inside the client window with the normal confirmation; only the
13
+ * person's confirmation installs. States: pending → shown → confirmed → installed | cancelled | failed.
14
+ */
15
+ import { createServer } from 'node:http';
16
+ import { randomUUID } from 'node:crypto';
17
+ import { AppHostError, requireCondition } from '../errors.js';
18
+
19
+ export const HANDOFF_STATES = Object.freeze(['pending', 'shown', 'confirmed', 'installed', 'cancelled', 'failed']);
20
+ const FINAL = new Set(['installed', 'cancelled', 'failed']);
21
+ /** Defaults (owner-approved 2026-10-15, plan §2); every one is visible and editable in the library settings. */
22
+ export const HANDOFF_DEFAULTS = Object.freeze({
23
+ enabled: true,
24
+ ports: Object.freeze([39317, 39318, 39319]),
25
+ allowedOrigins: Object.freeze(['https://market.hanamesh.com']),
26
+ ttlMs: 600_000,
27
+ pollMs: 1_500,
28
+ });
29
+ /** Largest accepted handoff request body. A target is itemId (≤160) + packageName (≤214) + contractVersion. */
30
+ export const HANDOFF_BODY_MAX_BYTES = 4096;
31
+ const TIMER_MAX_MS = 2_147_483_647;
32
+ /** Fixed rules (correctness, not policy): listed on the settings page with the reason they cannot be switched off. */
33
+ export const HANDOFF_RULES = Object.freeze([
34
+ { rule: '只在本机 127.0.0.1 上监听,请求的 Host 必须是 127.0.0.1 或 localhost 加监听端口', why: '防止别的机器或 DNS 重绑定的网页冒充本机' },
35
+ { rule: '只有「允许交接的网站」里的网站能探测本客户端、交接安装、读交接状态;别的网站什么也读不到', why: '否则任何网页都能知道你装了什么客户端、缺什么,用来识别你' },
36
+ { rule: '网站交接只会在应用库里弹出确认;只有你点「确认安装」才会安装', why: '网站不能替你决定装什么' },
37
+ { rule: '交接状态只给发起它的那个网站看', why: '一个网站不能读另一个网站的交接' },
38
+ { rule: `交接请求体不超过 ${HANDOFF_BODY_MAX_BYTES} 字节`, why: '合法请求只有插件 id、包名、合约版本三项,最长不到 500 字节;更大的一定不是交接' },
39
+ { rule: '端口在 1024–65535 之间、互不重复,至少一个', why: '1024 以下要管理员权限;没有端口就没有地方让网站找到' },
40
+ { rule: '网站必须写成来源(协议 + 域名 [+ 端口]),http 只允许 127.0.0.1 / localhost', why: '浏览器按来源校验;明文 http 的公网网站可被中间人冒充' },
41
+ { rule: `轮询间隔是 1–${TIMER_MAX_MS} 毫秒的整数,作废时间必须大于轮询间隔`, why: '计时器超过上限会立即触发;作废早于第一次轮询,交接永远不会被看到' },
42
+ ]);
43
+ export const HANDOFF_SETTING_NAMES = Object.freeze({ enabled: 'library.handoff.enabled', ports: 'library.handoff.ports',
44
+ allowedOrigins: 'library.handoff.allowedOrigins', ttlMs: 'library.handoff.ttlMs', pollMs: 'library.handoff.pollMs' });
45
+ const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
46
+
47
+ /** Validate and normalise handoff settings; every field is required after merging with the defaults. */
48
+ export function handoffSettings(input = {}) {
49
+ const value = { ...HANDOFF_DEFAULTS, ...Object.fromEntries(Object.entries(input ?? {}).filter(([, v]) => v !== undefined)) };
50
+ const invalid = message => new AppHostError('INVALID_HANDOFF_SETTINGS', message, {}, 400);
51
+ if (Object.keys(value).some(key => !(key in HANDOFF_DEFAULTS))) throw invalid('Unknown handoff setting.');
52
+ if (typeof value.enabled !== 'boolean') throw invalid('enabled must be true or false.');
53
+ if (!Array.isArray(value.ports) || value.ports.length < 1 ||
54
+ value.ports.some(port => !Number.isInteger(port) || port < 1024 || port > 65535) || new Set(value.ports).size !== value.ports.length)
55
+ throw invalid('ports must be at least one distinct port between 1024 and 65535.');
56
+ if (!Array.isArray(value.allowedOrigins)) throw invalid('allowedOrigins must be a list of origins.');
57
+ const origins = value.allowedOrigins.map(origin => {
58
+ let url; try { url = new URL(origin); } catch { throw invalid(`Not an origin: ${origin}`); }
59
+ if (!['https:', 'http:'].includes(url.protocol) || url.origin !== origin.replace(/\/$/, '')) throw invalid(`Not an origin: ${origin}`);
60
+ if (url.protocol === 'http:' && !['127.0.0.1', 'localhost'].includes(url.hostname)) throw invalid(`Plain http is only accepted for loopback: ${origin}`);
61
+ return url.origin;
62
+ });
63
+ if (!Number.isInteger(value.pollMs) || value.pollMs < 1 || value.pollMs > TIMER_MAX_MS) throw invalid(`pollMs must be an integer between 1 and ${TIMER_MAX_MS}.`);
64
+ if (!Number.isSafeInteger(value.ttlMs) || value.ttlMs <= value.pollMs) throw invalid('ttlMs must be an integer greater than pollMs.');
65
+ return { enabled: value.enabled, ports: [...value.ports], allowedOrigins: [...new Set(origins)], ttlMs: value.ttlMs, pollMs: value.pollMs };
66
+ }
67
+
68
+ /**
69
+ * In-memory handoffs. `observe` links library install events to the handoff shown for the same package. `clientPolled`
70
+ * records when the client window last fetched open handoffs, so an expiry can say whether the window ever saw it.
71
+ * A failing subscriber is reported through `onError` (default: console.warn) and does not stop the others.
72
+ */
73
+ export function createHandoffQueue({ ttlMs = () => HANDOFF_DEFAULTS.ttlMs, now = Date.now, onError = (message, error) => console.warn(message, error) } = {}) {
74
+ const records = new Map(), listeners = new Set(); let lastPolledAt = null;
75
+ const publish = record => { for (const listener of listeners) { try { listener(view(record)); } catch (error) { onError('hanamesh-app-host handoff: subscriber failed', error); } } };
76
+ const view = record => ({ handoffId: record.handoffId, state: record.state, origin: record.origin,
77
+ ...(record.itemId ? { itemId: record.itemId } : {}), ...(record.packageName ? { packageName: record.packageName } : {}),
78
+ ...(record.displayName ? { displayName: record.displayName } : {}), ...(record.result ? { result: record.result } : {}),
79
+ ...(record.error ? { error: record.error } : {}), createdAt: record.createdAt, updatedAt: record.updatedAt });
80
+ const move = (record, state, extra = {}) => {
81
+ if (FINAL.has(record.state)) return false;
82
+ Object.assign(record, extra, { state, updatedAt: now() }); publish(record); return true;
83
+ };
84
+ const expire = () => {
85
+ for (const record of records.values()) {
86
+ if (!FINAL.has(record.state) && now() - record.createdAt > ttlMs())
87
+ move(record, 'failed', { error: record.state === 'pending'
88
+ ? { code: 'HANDOFF_NOT_PICKED_UP', message: lastPolledAt === null || lastPolledAt < record.createdAt
89
+ ? '交接已过期:客户端的应用库窗口一直没有来取(窗口没打开,或应用库读取交接失败,见应用库提示)。'
90
+ : '交接已过期:应用库取到了交接,但没能弹出确认,也没有报回原因。', lastPolledAt }
91
+ : { code: 'HANDOFF_EXPIRED', message: record.state === 'shown'
92
+ ? '交接已过期:确认框已经弹出,但到期前没有收到「确认」或「取消」(如果点了取消,说明取消没能报回,见应用库提示)。'
93
+ : '交接已过期:已确认安装,但到期前没有收到安装结果。' } });
94
+ if (FINAL.has(record.state) && now() - record.updatedAt > ttlMs()) records.delete(record.handoffId);
95
+ }
96
+ };
97
+ return Object.freeze({
98
+ create({ origin, target, item, alreadyInstalled }) {
99
+ expire();
100
+ const at = now(), record = { handoffId: randomUUID(), state: 'pending', origin, target,
101
+ itemId: item.id, packageName: item.package?.name, displayName: item.displayName, createdAt: at, updatedAt: at };
102
+ records.set(record.handoffId, record);
103
+ if (alreadyInstalled) move(record, 'installed', { result: { status: 'already-installed' } }); else publish(record);
104
+ return view(record);
105
+ },
106
+ get(handoffId) {
107
+ expire();
108
+ requireCondition(typeof handoffId === 'string' && ID.test(handoffId) && records.has(handoffId), 'HANDOFF_UNKNOWN', 'Unknown handoff.', {}, 404);
109
+ return view(records.get(handoffId));
110
+ },
111
+ /** Handoffs the client window still has to show or is showing. */
112
+ open() { expire(); return [...records.values()].filter(record => ['pending', 'shown', 'confirmed'].includes(record.state)).map(view); },
113
+ clientPolled() { lastPolledAt = now(); },
114
+ lastPolledAt: () => lastPolledAt,
115
+ /**
116
+ * Client window reports: shown | cancelled | failed (the confirmation could not be opened; `error.code` says why).
117
+ * Installs are linked from library events, never from a client claim.
118
+ */
119
+ report(input) {
120
+ requireCondition(input && typeof input === 'object' && Object.keys(input).every(key => ['handoffId', 'state', 'error'].includes(key)) &&
121
+ ['shown', 'cancelled', 'failed'].includes(input.state) && (input.state === 'failed'
122
+ ? input.error && typeof input.error === 'object' && Object.keys(input.error).every(key => key === 'code') && typeof input.error.code === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(input.error.code)
123
+ : input.error === undefined),
124
+ 'INVALID_INPUT', 'Only handoffId and state shown|cancelled, or failed with error.code, are accepted.');
125
+ const record = records.get(this.get(input.handoffId).handoffId);
126
+ if (input.state === 'shown' && record.state !== 'pending') return view(record);
127
+ move(record, input.state, input.state === 'failed'
128
+ ? { error: { code: 'HANDOFF_OPEN_FAILED', message: `应用库没能弹出这个安装确认:${input.error.code}`, cause: input.error.code } } : {});
129
+ return view(record);
130
+ },
131
+ observe(event) {
132
+ const name = event?.packageName ?? event?.result?.packageName; if (!name) return;
133
+ for (const record of records.values()) {
134
+ if (record.packageName !== name || !['shown', 'confirmed'].includes(record.state)) continue;
135
+ if (event.type === 'library.install-started') move(record, 'confirmed');
136
+ else if (event.type === 'library.install-done') move(record, 'installed', { result: { status: event.status ?? event.result?.status } });
137
+ else if (event.type === 'library.install-failed') move(record, 'failed', { error: { code: event.code ?? 'INSTALL_FAILED', message: '安装失败' } });
138
+ }
139
+ },
140
+ subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
141
+ });
142
+ }
143
+
144
+ function send(res, status, value, headers = {}) {
145
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff',
146
+ 'content-security-policy': "default-src 'none'; frame-ancestors 'none'", ...headers });
147
+ res.end(value === undefined ? '' : JSON.stringify(value));
148
+ }
149
+ async function jsonBody(req) {
150
+ requireCondition(req.headers['content-type']?.split(';')[0].trim() === 'application/json', 'CONTENT_TYPE_REQUIRED', 'Expected application/json.', {}, 415);
151
+ const chunks = []; let total = 0;
152
+ for await (const chunk of req) { total += chunk.length; requireCondition(total <= HANDOFF_BODY_MAX_BYTES, 'BODY_TOO_LARGE', `Request body is larger than ${HANDOFF_BODY_MAX_BYTES} bytes.`, { maxBytes: HANDOFF_BODY_MAX_BYTES }, 413); chunks.push(chunk); }
153
+ try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { throw new AppHostError('INVALID_JSON', 'Malformed JSON.', {}, 400); }
154
+ }
155
+
156
+ /**
157
+ * HTTP handler of the handoff listener. `library`: installTarget() and resolveTarget(); `settings()`: current settings;
158
+ * `port()`: the bound port (Host must be exactly 127.0.0.1:<port> or localhost:<port>).
159
+ */
160
+ export function createHandoffHandler({ library, queue, settings, port }) {
161
+ return async (req, res) => {
162
+ const traceId = randomUUID(); let cors = {};
163
+ try {
164
+ const bound = port();
165
+ requireCondition([`127.0.0.1:${bound}`, `localhost:${bound}`].includes(req.headers.host), 'HOST_DENIED', 'Unrecognized request host.', {}, 403);
166
+ const url = new URL(req.url ?? '/', `http://${req.headers.host}`), path = url.pathname, origin = req.headers.origin;
167
+ if (path === '/hanamesh/library/install-target') {
168
+ // A website not in allowedOrigins gets no CORS grant and no body: it cannot tell which client runs or what it lacks.
169
+ // Requests without an Origin are not from a website (a person's address bar, a local tool) and are answered.
170
+ if (origin !== undefined) {
171
+ requireCondition(settings().allowedOrigins.includes(origin), 'ORIGIN_DENIED', 'Not available.', undefined, 403);
172
+ cors = { 'access-control-allow-origin': origin, vary: 'Origin', 'access-control-allow-private-network': 'true' };
173
+ }
174
+ if (req.method === 'OPTIONS') { send(res, 204, undefined, { ...cors, 'access-control-allow-methods': 'GET' }); return; }
175
+ requireCondition(req.method === 'GET', 'METHOD_NOT_ALLOWED', 'Discovery requires GET.', {}, 405);
176
+ requireCondition(!url.search, 'UNKNOWN_FIELDS', 'Discovery takes no parameters.');
177
+ send(res, 200, { ...library.installTarget(), traceId }, cors); return;
178
+ }
179
+ if(path === '/hanamesh/library/installation-status'){
180
+ cors=installationStatusCors(req,settings().allowedOrigins);
181
+ await serveInstallationStatus(req,res,{read:library.installationStatus,maxBodyBytes:HANDOFF_BODY_MAX_BYTES,traceId,cors,send});return;
182
+ }
183
+ requireCondition(path === '/hanamesh/library/handoff', 'NOT_FOUND', 'Unknown handoff route.', {}, 404);
184
+ requireCondition(typeof origin === 'string' && settings().allowedOrigins.includes(origin), 'ORIGIN_DENIED',
185
+ 'This website is not allowed to hand installs to this client. Add it in the HanaMesh library settings.', { origin: origin ?? null }, 403);
186
+ cors = { 'access-control-allow-origin': origin, vary: 'Origin', 'access-control-allow-private-network': 'true' };
187
+ if (req.method === 'OPTIONS') {
188
+ send(res, 204, undefined, { ...cors, 'access-control-allow-methods': 'GET, POST', 'access-control-allow-headers': 'content-type' });
189
+ return;
190
+ }
191
+ if (req.method === 'GET') {
192
+ requireCondition([...url.searchParams.keys()].length === 1 && url.searchParams.has('id'), 'UNKNOWN_FIELDS', 'Only id is accepted.');
193
+ const record = queue.get(url.searchParams.get('id'));
194
+ requireCondition(record.origin === origin, 'HANDOFF_UNKNOWN', 'Unknown handoff.', {}, 404);
195
+ const { origin: _o, createdAt: _c, updatedAt: _u, ...visible } = record;
196
+ send(res, 200, { ...visible, traceId }, cors); return;
197
+ }
198
+ requireCondition(req.method === 'POST', 'METHOD_NOT_ALLOWED', 'Unsupported handoff method.', {}, 405);
199
+ requireCondition(!url.search, 'UNKNOWN_FIELDS', 'Handoff takes no query.');
200
+ const target = await jsonBody(req);
201
+ const probe = library.installTarget();
202
+ requireCondition(probe.ready, 'INSTALL_TARGET_UNAVAILABLE', probe.error?.message ?? 'Controlled installer is unavailable.', { need: probe.error?.need ?? [] }, 503);
203
+ const { item } = await library.resolveTarget(target);
204
+ requireCondition(item.kind !== 'listing', 'NOT_INSTALLABLE', 'Catalog item has no npm package to install.');
205
+ const installed = item.installed && ['registered', 'installed', 'installed-not-loaded'].includes(item.installed.state) && !item.upgradeAvailable;
206
+ const record = queue.create({ origin, target, item, alreadyInstalled: installed });
207
+ send(res, 202, { handoffId: record.handoffId, state: record.state, ...(record.result ? { result: record.result } : {}), traceId }, cors);
208
+ } catch (error) {
209
+ const trusted = error instanceof AppHostError;
210
+ send(res, trusted ? error.status : 500, { error: { code: trusted ? error.code : 'INTERNAL_ERROR',
211
+ message: trusted ? error.message : 'The operation could not be completed.', ...(trusted && error.details ? { details: error.details } : {}) }, traceId }, cors);
212
+ }
213
+ };
214
+ }
215
+
216
+ /**
217
+ * The listener itself. `start()` binds 127.0.0.1 on the first free port of settings().ports and reports which one, or
218
+ * why none could be used (`need`), so the library settings and probes can say it.
219
+ */
220
+ export function createHandoffListener({ handlerFor, settings, logger, listen = (server, port) => new Promise((resolve, reject) => {
221
+ const fail = error => { server.off('listening', ok); reject(error); }, ok = () => { server.off('error', fail); resolve(); };
222
+ server.once('error', fail); server.once('listening', ok); server.listen(port, '127.0.0.1');
223
+ }) }) {
224
+ let server, state = { status: 'stopped' };
225
+ async function stop() {
226
+ const current = server; server = undefined;
227
+ if (current) await new Promise(resolve => { current.close(() => resolve()); current.closeAllConnections?.(); });
228
+ state = { status: 'stopped' };
229
+ }
230
+ async function start() {
231
+ await stop();
232
+ const { enabled, ports } = settings();
233
+ if (!enabled) { state = { status: 'disabled' }; return state; }
234
+ const tried = [];
235
+ for (const port of ports) {
236
+ const candidate = createServer();
237
+ candidate.on('request', handlerFor(() => port));
238
+ try { await listen(candidate, port); server = candidate; state = { status: 'listening', port, origin: `http://127.0.0.1:${port}` }; logger?.info?.('hanamesh-app-host handoff: listening on 127.0.0.1:%d', port); return state; }
239
+ catch (error) { tried.push({ port, code: error.code ?? 'LISTEN_FAILED' }); candidate.close(); }
240
+ }
241
+ state = { status: 'unavailable', tried, need: `网站交接端口 ${ports.join('、')} 都被占用:在应用库设置里换一个空闲端口,并告诉网站同一端口` };
242
+ logger?.warn?.('hanamesh-app-host handoff: no free port %s', JSON.stringify(tried));
243
+ return state;
244
+ }
245
+ return Object.freeze({ start, stop, state: () => state });
246
+ }