@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.
- package/LICENSE +4 -7
- package/README.md +140 -13
- package/consistency.json +41 -6
- package/dist/catalog/fixtures/consumer.json +35 -0
- package/dist/catalog/fixtures/provider.json +107 -0
- package/dist/catalog/suite.d.ts +4 -0
- package/dist/catalog/suite.js +50 -0
- package/dist/client-ui.d.ts +21 -3
- package/dist/client-ui.js +961 -94
- package/dist/client.js +2 -2
- package/dist/control-origin.js +9 -0
- package/dist/descriptor.js +6 -0
- package/dist/dsh-compat.js +6 -0
- package/dist/dsh.d.ts +21 -4
- package/dist/dsh.js +149 -58
- package/dist/gateway.js +140 -11
- package/dist/git-process.js +64 -0
- package/dist/git-worker.js +50 -0
- package/dist/index.d.ts +9 -7
- package/dist/install-target/fixtures/catalog.json +58 -0
- package/dist/install-target/fixtures/consumer-cases.json +148 -0
- package/dist/install-target/fixtures/install-failure-cases.json +58 -0
- package/dist/install-target/fixtures/provider-cases.json +618 -0
- package/dist/install-target/fixtures/reference-consumer.js +37 -0
- package/dist/install-target/fixtures/source-profile.js +123 -0
- package/dist/install-target/harness.js +43 -0
- package/dist/install-target/run.js +28 -0
- package/dist/install-target/suite.d.ts +19 -0
- package/dist/install-target/suite.js +328 -0
- package/dist/installation-status.d.ts +21 -0
- package/dist/installation-status.js +2 -0
- package/dist/library/catalog.js +163 -19
- package/dist/library/client-identity.js +31 -0
- package/dist/library/desktop-cli.js +7 -0
- package/dist/library/domain.js +3 -1
- package/dist/library/handoff.js +246 -0
- package/dist/library/install.js +138 -32
- package/dist/library/installation-cli.js +48 -0
- package/dist/library/installation-command-worker.js +27 -0
- package/dist/library/installation-status-http.js +26 -0
- package/dist/library/installation-status.js +91 -0
- package/dist/library/installation-worker.js +44 -0
- package/dist/library/locate.js +70 -9
- package/dist/library/node-binding.js +11 -0
- package/dist/library/recovery.js +66 -0
- package/dist/library/routes.js +90 -14
- package/dist/library/service.js +123 -27
- package/dist/library/suite.js +130 -0
- package/dist/manager.js +24 -8
- package/dist/node-runtime/domain.js +6 -0
- package/dist/node-runtime/releases.json +59 -0
- package/dist/node-runtime/service.js +101 -0
- package/dist/owned-probe-diagnostic.js +53 -0
- package/dist/router/receipts.js +8 -11
- package/dist/router/routes.js +7 -7
- package/dist/routes.js +9 -8
- package/dist/runtime.js +11 -1
- package/dist/usage-evidence.js +18 -86
- package/dist/workspace-origin.js +17 -0
- package/docs/APP_PACKAGE.md +4 -0
- package/docs/CONTRACT.md +75 -11
- package/docs/DSH_COMPATIBILITY.md +94 -0
- package/docs/DSH_INTEGRATION.md +41 -0
- package/docs/INSTALLATION_STATUS.md +96 -0
- package/docs/INSTALL_TARGET.md +130 -0
- package/docs/LIBRARY.md +44 -3
- package/docs/LICENSES.json +189 -61
- package/docs/PROVENANCE.json +53 -12
- package/docs/SECURITY.md +31 -0
- package/node_modules/@hanamesh/lib-provision/LICENSE +15 -0
- package/node_modules/@hanamesh/lib-provision/README.md +134 -0
- package/node_modules/@hanamesh/lib-provision/THIRD_PARTY_NOTICES.md +17 -0
- package/node_modules/@hanamesh/lib-provision/bin/hanamesh-provision.js +13 -0
- package/node_modules/@hanamesh/lib-provision/consistency.json +20 -0
- package/node_modules/@hanamesh/lib-provision/contract/CHANGELOG.md +5 -0
- package/node_modules/@hanamesh/lib-provision/contract/conformance.mjs +80 -0
- package/node_modules/@hanamesh/lib-provision/contract/fixtures/consumer.json +9 -0
- package/node_modules/@hanamesh/lib-provision/contract/fixtures/provider.json +11 -0
- package/node_modules/@hanamesh/lib-provision/contract/lib-provision.schema.json +47 -0
- package/node_modules/@hanamesh/lib-provision/package.json +50 -0
- package/package.json +81 -33
- package/schemas/catalog-provider-page.schema.json +884 -7
- package/schemas/catalog-source.schema.json +9 -7
- package/schemas/install-target.schema.json +298 -0
- package/dist/provision/LICENSE +0 -6
- package/dist/provision/THIRD_PARTY_NOTICES.md +0 -13
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/archive.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/archive.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/check.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/check.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/checkpoints.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/checkpoints.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/cli.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/cli.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/download.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/download.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/errors.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/errors.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/extract.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/extract.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/fsutil.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/fsutil.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/index.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/index.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/ledger.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/ledger.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/manifest.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/manifest.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/paths.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/paths.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/platform.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/platform.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/provision.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/provision.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/tar.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/tar.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/types.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/types.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/zip.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/zip.js +0 -0
package/dist/gateway.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { createServer, request } from 'node:http';
|
|
2
2
|
import { randomBytes, createHash } from 'node:crypto';
|
|
3
3
|
import { AppHostError, requireCondition, bounded } from './errors.js';
|
|
4
|
-
import {
|
|
4
|
+
import { documentOrigin, DESKTOP_ORIGIN } from './workspace-origin.js';
|
|
5
|
+
import { loopbackOrigin, workspaceOrigin } from './descriptor.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* `frame-ancestors` is matched against EVERY ancestor, not just the direct parent. Inside a
|
|
@@ -15,6 +16,7 @@ export function frameAncestorsValue(parentOrigin, frameAncestors = []) {
|
|
|
15
16
|
}
|
|
16
17
|
/** Only exact origins of a scheme a webview can actually host a top-level document on. */
|
|
17
18
|
export function ancestorOrigin(value) {
|
|
19
|
+
if (value === DESKTOP_ORIGIN) return value;
|
|
18
20
|
let url;
|
|
19
21
|
try { url = new URL(value); } catch { requireCondition(false, 'INVALID_FRAME_ANCESTOR', 'Expected an explicit ancestor origin.'); }
|
|
20
22
|
requireCondition(['http:', 'https:', 'tauri:', 'asset:', 'file:'].includes(url.protocol) && !url.username && !url.password &&
|
|
@@ -40,6 +42,8 @@ export function embeddingHeaders(rawHeaders, ancestors) {
|
|
|
40
42
|
return output;
|
|
41
43
|
}
|
|
42
44
|
const token = () => randomBytes(32).toString('hex');
|
|
45
|
+
// Keep retired names reserved for the process lifetime; 192-bit labels fit DNS's 63-byte limit.
|
|
46
|
+
const desktopNames = new Set();
|
|
43
47
|
function cookies(header) {
|
|
44
48
|
const map = new Map();
|
|
45
49
|
for (const part of (header ?? '').split(';')) {
|
|
@@ -55,7 +59,7 @@ function fail(res, status, code) {
|
|
|
55
59
|
/** Fixed upstream only. Bootstrap is our own response, not a rewritten app document. */
|
|
56
60
|
export class FixedGateway {
|
|
57
61
|
constructor({ upstream, parentOrigin, isLeaseActive, cookieAllowlist = [], allowAppAuthorization = false, maxUploadBytes = 32 * 1024 * 1024, frameAncestors = [], onForward = () => {} }) {
|
|
58
|
-
this.upstream = loopbackOrigin(upstream); this.parentOrigin =
|
|
62
|
+
this.upstream = loopbackOrigin(upstream); this.parentOrigin = workspaceOrigin(parentOrigin);
|
|
59
63
|
requireCondition(Array.isArray(frameAncestors) && frameAncestors.length <= 8, 'INVALID_FRAME_ANCESTOR', 'frameAncestors must be a short list of origins.');
|
|
60
64
|
this.ancestors = frameAncestorsValue(this.parentOrigin, frameAncestors.map(ancestorOrigin));
|
|
61
65
|
requireCondition(typeof isLeaseActive === 'function','INVALID_GATEWAY','A live lease authorization check is required.');
|
|
@@ -63,6 +67,7 @@ export class FixedGateway {
|
|
|
63
67
|
this.allowAppAuthorization = allowAppAuthorization; this.maxUploadBytes = maxUploadBytes;
|
|
64
68
|
requireCondition(typeof onForward === 'function','INVALID_GATEWAY','onForward must be a function.');
|
|
65
69
|
this.onForward = onForward;
|
|
70
|
+
this.children = new Map(); this.pendingChildren = new Map(); this.scopeKey = null; this.closed = false; this.authorizations = new Map();
|
|
66
71
|
this.tickets = new Map(); this.grants = new Map(); this.sockets = new Set(); this.upstreams = new Set();
|
|
67
72
|
this.cookiePrefix = `hm_app_${token().slice(0,12)}_`;
|
|
68
73
|
}
|
|
@@ -72,7 +77,14 @@ export class FixedGateway {
|
|
|
72
77
|
this.server.on('upgrade', (req,socket,head) => this.#upgrade(req,socket,head));
|
|
73
78
|
this.server.on('connect', (_req,socket) => socket.end('HTTP/1.1 405 Method Not Allowed\r\nConnection: close\r\n\r\n'));
|
|
74
79
|
await new Promise((resolve,reject) => { this.server.once('error',reject); this.server.listen(0,'127.0.0.1',resolve); });
|
|
75
|
-
|
|
80
|
+
const port = this.server.address().port;
|
|
81
|
+
if (this.parentOrigin === DESKTOP_ORIGIN && this.scopeKey) {
|
|
82
|
+
const name = token().slice(0,48);
|
|
83
|
+
if (desktopNames.has(name)) { await this.close(); throw new AppHostError('GATEWAY_ORIGIN_REUSED','A retired origin name cannot be reassigned.'); }
|
|
84
|
+
desktopNames.add(name);
|
|
85
|
+
this.origin = `http://control-${name}.localhost:${port}`;
|
|
86
|
+
this.dataOrigin = `http://data-${name}.localhost:${port}`;
|
|
87
|
+
} else this.origin = `http://127.0.0.1:${port}`;
|
|
76
88
|
return this.origin;
|
|
77
89
|
}
|
|
78
90
|
issue(viewKey) {
|
|
@@ -83,16 +95,115 @@ export class FixedGateway {
|
|
|
83
95
|
const ticket = token(); this.tickets.set(ticket,{ viewKey, expiresAt:Date.now()+60_000 });
|
|
84
96
|
return `${this.origin}/__hanamesh_bootstrap/${ticket}`;
|
|
85
97
|
}
|
|
98
|
+
async issueAsync(viewKey) {
|
|
99
|
+
if (this.parentOrigin !== DESKTOP_ORIGIN) return this.issue(viewKey);
|
|
100
|
+
requireCondition(!this.closed && this.isLeaseActive(viewKey),'LEASE_EXPIRED','Inactive view.');
|
|
101
|
+
this.revokeInactive();
|
|
102
|
+
let child = this.children.get(viewKey);
|
|
103
|
+
if (!child) {
|
|
104
|
+
let pending = this.pendingChildren.get(viewKey);
|
|
105
|
+
if (!pending) {
|
|
106
|
+
requireCondition(this.children.size + this.pendingChildren.size < 512,'TOO_MANY_TICKETS','Too many views.');
|
|
107
|
+
pending = (async () => {
|
|
108
|
+
const scoped = new FixedGateway({upstream:this.upstream,parentOrigin:this.parentOrigin,frameAncestors:this.ancestors.split(' ').slice(1),
|
|
109
|
+
isLeaseActive:key => key === viewKey && this.isLeaseActive(key),cookieAllowlist:[...this.cookieAllowlist],
|
|
110
|
+
allowAppAuthorization:this.allowAppAuthorization,maxUploadBytes:this.maxUploadBytes,onForward:this.onForward});
|
|
111
|
+
scoped.scopeKey = viewKey;
|
|
112
|
+
await scoped.start();
|
|
113
|
+
if (this.closed || !this.isLeaseActive(viewKey)) { await scoped.close(); throw new AppHostError('LEASE_EXPIRED','Inactive view.'); }
|
|
114
|
+
this.children.set(viewKey,scoped);
|
|
115
|
+
scoped.expiryTimer = setInterval(() => { if (!scoped.isLeaseActive(viewKey)) void scoped.close(); },50);
|
|
116
|
+
scoped.expiryTimer.unref();
|
|
117
|
+
return scoped;
|
|
118
|
+
})();
|
|
119
|
+
this.pendingChildren.set(viewKey,pending);
|
|
120
|
+
}
|
|
121
|
+
try { child = await pending; }
|
|
122
|
+
finally { if (this.pendingChildren.get(viewKey) === pending) this.pendingChildren.delete(viewKey); }
|
|
123
|
+
}
|
|
124
|
+
return child.issue(viewKey);
|
|
125
|
+
}
|
|
126
|
+
authorizeChallenge(viewKey, gatewayOrigin, challenge) {
|
|
127
|
+
const child = this.children.get(viewKey), item = child?.tickets.get(challenge);
|
|
128
|
+
requireCondition(child && !child.closed && child.origin === gatewayOrigin && item && item.viewKey === viewKey &&
|
|
129
|
+
item.loaded && !item.authorized && item.expiresAt > Date.now() && child.isLeaseActive(viewKey),
|
|
130
|
+
'GATEWAY_CHALLENGE_DENIED','Invalid or inactive challenge.',{},403);
|
|
131
|
+
const authorization = token(); item.authorized = true;
|
|
132
|
+
child.authorizations.set(authorization,{challenge,viewKey,expiresAt:item.expiresAt});
|
|
133
|
+
return {authorization};
|
|
134
|
+
}
|
|
135
|
+
revokeInactive() {
|
|
136
|
+
for (const [key,child] of this.children) if (!this.isLeaseActive(key) || child.closed) {
|
|
137
|
+
this.children.delete(key); void child.close();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
#desktopBootstrap(req,res) {
|
|
141
|
+
const challenge = req.url.slice('/__hanamesh_bootstrap/'.length), item = this.tickets.get(challenge);
|
|
142
|
+
if (!this.scopeKey || req.method !== 'GET' || req.headers['sec-fetch-dest'] !== 'iframe' ||
|
|
143
|
+
!item || item.loaded || item.expiresAt <= Date.now() || !this.isLeaseActive(item.viewKey)) return fail(res,403,'GATEWAY_BOOTSTRAP_DENIED');
|
|
144
|
+
item.loaded = true;
|
|
145
|
+
const nonce = token();
|
|
146
|
+
const body = `<!doctype html><meta charset="utf-8"><title>应用连接中</title><p>应用连接中…</p><script nonce="${nonce}">
|
|
147
|
+
const challenge=${JSON.stringify(challenge)}, gatewayOrigin=${JSON.stringify(this.origin)};
|
|
148
|
+
let proved=false, redeemed=false;
|
|
149
|
+
addEventListener('message',async event=>{
|
|
150
|
+
const exact=event.origin==='dsh-app://app'&&event.source===parent;
|
|
151
|
+
console.info('HM_BOOTSTRAP_PROOF',JSON.stringify({origin:event.origin,parent:event.source===parent,accepted:exact}));
|
|
152
|
+
if(!exact||event.data?.challenge!==challenge)return;
|
|
153
|
+
if(event.data.type==='hm-bootstrap-parent'&&!proved){proved=true;parent.postMessage({type:'hm-bootstrap-proof',challenge},'dsh-app://app');}
|
|
154
|
+
if(event.data.type==='hm-bootstrap-authorize'&&proved&&!redeemed&&typeof event.data.authorization==='string'){
|
|
155
|
+
redeemed=true;
|
|
156
|
+
const response=await fetch('/__hanamesh_exchange',{method:'POST',credentials:'omit',headers:{'content-type':'application/json'},
|
|
157
|
+
body:JSON.stringify({challenge,authorization:event.data.authorization})});
|
|
158
|
+
if(response.ok)location.replace(${JSON.stringify(this.dataOrigin+'/')});else document.querySelector('p').textContent='应用授权失败';
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
parent.postMessage({type:'hm-bootstrap-ready',challenge},'dsh-app://app');
|
|
162
|
+
</script>`;
|
|
163
|
+
res.writeHead(200,{'content-type':'text/html; charset=utf-8','cache-control':'no-store','referrer-policy':'origin',
|
|
164
|
+
'content-security-policy':`default-src 'none'; script-src 'nonce-${nonce}'; connect-src 'self'; frame-ancestors ${this.ancestors}`});
|
|
165
|
+
res.end(body);
|
|
166
|
+
}
|
|
167
|
+
async #exchange(req,res) {
|
|
168
|
+
if (!this.scopeKey || req.method !== 'POST' || req.headers.origin !== this.origin || req.headers['sec-fetch-site'] !== 'same-origin' ||
|
|
169
|
+
req.headers['content-type'] !== 'application/json') return fail(res,403,'GATEWAY_EXCHANGE_DENIED');
|
|
170
|
+
let body='';
|
|
171
|
+
for await (const chunk of req) { body+=chunk; if(body.length>1024)return fail(res,413,'BODY_TOO_LARGE'); }
|
|
172
|
+
let input; try { input=JSON.parse(body); } catch { return fail(res,400,'INVALID_JSON'); }
|
|
173
|
+
const grant=this.authorizations.get(input.authorization);
|
|
174
|
+
if(!grant || Object.keys(input).some(k=>!['challenge','authorization'].includes(k)) || grant.challenge!==input.challenge ||
|
|
175
|
+
grant.expiresAt<=Date.now() || !this.isLeaseActive(grant.viewKey))return fail(res,403,'GATEWAY_EXCHANGE_DENIED');
|
|
176
|
+
this.authorizations.delete(input.authorization);this.tickets.delete(grant.challenge);
|
|
177
|
+
this.grants.clear();this.grants.set(token(),{viewKey:grant.viewKey,initialNavigation:true,navigated:false});
|
|
178
|
+
res.writeHead(204,{'cache-control':'no-store'});res.end();
|
|
179
|
+
}
|
|
180
|
+
#desktopAuthorized(req) {
|
|
181
|
+
if (!this.scopeKey || this.closed || req.headers.host !== new URL(this.dataOrigin).host || !this.isLeaseActive(this.scopeKey)) return false;
|
|
182
|
+
const grant = [...this.grants.values()].find(g => g.viewKey === this.scopeKey);
|
|
183
|
+
if (!grant) return false;
|
|
184
|
+
let referer;try {referer=documentOrigin(req.headers.referer);}catch{}
|
|
185
|
+
// One natural control-to-data navigation, tied to the redeemed complete live view binding.
|
|
186
|
+
if (grant.initialNavigation && req.method === 'GET' && req.url === '/' && !req.headers.origin &&
|
|
187
|
+
req.headers['sec-fetch-dest'] === 'iframe' && req.headers['sec-fetch-mode'] === 'navigate' &&
|
|
188
|
+
req.headers['sec-fetch-site'] === 'cross-site' && referer === this.origin) {
|
|
189
|
+
grant.initialNavigation = false; grant.navigated = true; return true;
|
|
190
|
+
}
|
|
191
|
+
if (!grant.navigated) return false;
|
|
192
|
+
if (req.headers.upgrade?.toLowerCase() === 'websocket') return req.headers.origin === this.dataOrigin;
|
|
193
|
+
return req.headers['sec-fetch-site'] === 'same-origin' &&
|
|
194
|
+
(req.headers.origin ? req.headers.origin === this.dataOrigin : referer === this.dataOrigin);
|
|
195
|
+
}
|
|
86
196
|
#requestShape(req) {
|
|
87
|
-
if (req.headers.host
|
|
197
|
+
if (![this.origin,this.dataOrigin].filter(Boolean).some(origin => req.headers.host === new URL(origin).host)) return false;
|
|
88
198
|
if (!req.url?.startsWith('/') || req.url.startsWith('//') || req.url.includes('\\') || req.url.length > 8_192) return false;
|
|
89
199
|
try { if (/^[\/\\]{2}|[\x00-\x1f]/.test(decodeURIComponent(req.url))) return false; } catch { return false; }
|
|
90
|
-
if (req.headers.origin && ![this.parentOrigin,this.origin].includes(req.headers.origin)) return false;
|
|
200
|
+
if (req.headers.origin && ![this.parentOrigin,this.origin,this.dataOrigin].includes(req.headers.origin)) return false;
|
|
91
201
|
if (req.headers['sec-fetch-dest'] === 'document') return false; // never top-level navigation
|
|
92
202
|
return true;
|
|
93
203
|
}
|
|
94
204
|
#authorized(req) {
|
|
95
205
|
if (!this.#requestShape(req)) return false;
|
|
206
|
+
if (this.parentOrigin === DESKTOP_ORIGIN) return this.#desktopAuthorized(req);
|
|
96
207
|
// Cookie capability + live lease. Origin/loopback alone never authenticate.
|
|
97
208
|
for (const [name,value] of cookies(req.headers.cookie)) {
|
|
98
209
|
if (!name.startsWith(this.cookiePrefix)) continue;
|
|
@@ -122,7 +233,7 @@ export class FixedGateway {
|
|
|
122
233
|
if (req.headers.upgrade?.toLowerCase() === 'websocket' && req.headers.origin === this.origin) return true;
|
|
123
234
|
if (site !== 'same-site' || req.headers['sec-fetch-dest'] !== 'iframe') return false;
|
|
124
235
|
let referer;
|
|
125
|
-
try { referer =
|
|
236
|
+
try { referer = documentOrigin(req.headers.referer); } catch { return false; }
|
|
126
237
|
return referer === this.parentOrigin;
|
|
127
238
|
}
|
|
128
239
|
#liveGrantExists() {
|
|
@@ -143,10 +254,11 @@ export class FixedGateway {
|
|
|
143
254
|
return headers;
|
|
144
255
|
}
|
|
145
256
|
#bootstrap(req,res) {
|
|
257
|
+
if (this.parentOrigin === DESKTOP_ORIGIN) return this.#desktopBootstrap(req,res);
|
|
146
258
|
const ticket = req.url.slice('/__hanamesh_bootstrap/'.length);
|
|
147
259
|
const item = this.tickets.get(ticket);
|
|
148
260
|
let referer;
|
|
149
|
-
try { referer =
|
|
261
|
+
try { referer = documentOrigin(req.headers.referer); } catch {}
|
|
150
262
|
if (req.method !== 'GET' || req.headers['sec-fetch-dest'] !== 'iframe' || referer !== this.parentOrigin ||
|
|
151
263
|
!item || item.expiresAt <= Date.now() || !this.isLeaseActive(item.viewKey)) return fail(res,403,'GATEWAY_BOOTSTRAP_DENIED');
|
|
152
264
|
this.tickets.delete(ticket);
|
|
@@ -174,13 +286,25 @@ export class FixedGateway {
|
|
|
174
286
|
#forwarded() { try { this.onForward(); } catch {} }
|
|
175
287
|
#handle(req,res) {
|
|
176
288
|
if (!this.#requestShape(req)) return fail(res,403,'GATEWAY_REQUEST_DENIED');
|
|
177
|
-
if (req.
|
|
289
|
+
if (this.parentOrigin === DESKTOP_ORIGIN && req.headers.host === new URL(this.origin).host && req.url === '/__hanamesh_exchange') {
|
|
290
|
+
void this.#exchange(req,res).catch(()=>{if(!res.headersSent)fail(res,400,'GATEWAY_EXCHANGE_DENIED');else res.destroy();});return;
|
|
291
|
+
}
|
|
292
|
+
if (this.parentOrigin === DESKTOP_ORIGIN) {
|
|
293
|
+
if (req.headers.host === new URL(this.origin).host) {
|
|
294
|
+
if (req.url.startsWith('/__hanamesh_bootstrap/')) return this.#bootstrap(req,res);
|
|
295
|
+
return fail(res,403,'GATEWAY_CONTROL_ONLY');
|
|
296
|
+
}
|
|
297
|
+
if (req.url.startsWith('/__hanamesh_bootstrap/') || req.url === '/__hanamesh_exchange') return fail(res,403,'GATEWAY_DATA_ONLY');
|
|
298
|
+
} else if (req.url.startsWith('/__hanamesh_bootstrap/')) return this.#bootstrap(req,res);
|
|
178
299
|
if (!this.#authorized(req)) return fail(res,403,'GATEWAY_AUTH_REQUIRED');
|
|
179
300
|
if (!['GET','HEAD','POST','PUT','PATCH','DELETE','OPTIONS'].includes(req.method)) return fail(res,405,'METHOD_NOT_ALLOWED');
|
|
180
301
|
if (Number(req.headers['content-length']) > this.maxUploadBytes) return fail(res,413,'UPLOAD_TOO_LARGE');
|
|
181
302
|
const upstream = request(this.upstream + req.url,{ method:req.method,headers:this.#headers(req) }, response => {
|
|
182
303
|
if (response.statusCode !== 401 && response.statusCode !== 403) this.#forwarded();
|
|
183
|
-
|
|
304
|
+
// A nested app frame has both the workspace and its own registered data origin as ancestors.
|
|
305
|
+
// Only the data response admits that exact live view origin; control/bootstrap stays separate.
|
|
306
|
+
const ancestors = this.dataOrigin ? `${this.ancestors} ${this.dataOrigin}` : this.ancestors;
|
|
307
|
+
res.writeHead(response.statusCode, embeddingHeaders(response.rawHeaders,ancestors));
|
|
184
308
|
response.pipe(res, { end:false });
|
|
185
309
|
response.once('error', () => res.destroy());
|
|
186
310
|
response.once('end', () => { if (Object.keys(response.trailers).length) res.addTrailers(response.trailers); res.end(); });
|
|
@@ -215,8 +339,13 @@ export class FixedGateway {
|
|
|
215
339
|
upstream.once('response',() => { clearTimeout(timer); upstream.destroy(); socket.end('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n'); });
|
|
216
340
|
upstream.once('error',() => { clearTimeout(timer); socket.destroy(); }); upstream.end();
|
|
217
341
|
}
|
|
218
|
-
|
|
219
|
-
|
|
342
|
+
close() { return this.closeTask ??= this.#close(); }
|
|
343
|
+
async #close() {
|
|
344
|
+
this.closed = true; clearInterval(this.expiryTimer);
|
|
345
|
+
this.tickets.clear(); this.grants.clear(); this.authorizations.clear();
|
|
346
|
+
await Promise.allSettled([...this.pendingChildren.values()]);
|
|
347
|
+
const children=[...this.children.values()];this.children.clear();
|
|
348
|
+
await Promise.all(children.map(child=>child.close()));
|
|
220
349
|
for (const request of this.upstreams) request.destroy();
|
|
221
350
|
for (const socket of this.sockets) socket.destroy();
|
|
222
351
|
if (this.server?.listening) await bounded(new Promise((resolve,reject) => this.server.close(error => error ? reject(error) : resolve())),2_000,'Gateway shutdown');
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Module-owned process boundary. No user Git config or credential store is modified.
|
|
2
|
+
import {fork} from 'node:child_process';
|
|
3
|
+
import {openSync,readSync,closeSync,rmSync} from 'node:fs';
|
|
4
|
+
import {join,isAbsolute} from 'node:path';
|
|
5
|
+
const policy=Object.freeze({GIT_CONFIG_NOSYSTEM:'1',GIT_TERMINAL_PROMPT:'0',GIT_CONFIG_COUNT:'2',
|
|
6
|
+
GIT_CONFIG_KEY_0:'credential.helper',GIT_CONFIG_VALUE_0:'',
|
|
7
|
+
GIT_CONFIG_KEY_1:'credential.helper',GIT_CONFIG_VALUE_1:'!gh auth git-credential'});
|
|
8
|
+
export function safeGitEnv(base=process.env){
|
|
9
|
+
const env={...base};
|
|
10
|
+
for(const key of Object.keys(env))if(/^GIT_CONFIG_(?:COUNT|KEY_\d+|VALUE_\d+|PARAMETERS)$/.test(key)||/^GIT_TRACE/.test(key)||['GIT_ASKPASS','SSH_ASKPASS'].includes(key))delete env[key];
|
|
11
|
+
return {...env,...policy,npm_config_manage_package_manager_versions:'false',pnpm_config_manage_package_manager_versions:'false'};
|
|
12
|
+
}
|
|
13
|
+
export function hasSafeGitEnv(env=process.env){return Object.entries(policy).every(([key,value])=>env[key]===value)&&!env.GIT_CONFIG_PARAMETERS;}
|
|
14
|
+
export function requireGuardedGitEnv(env=process.env){
|
|
15
|
+
if(!hasSafeGitEnv(env)||!env.GIT_TRACE2_EVENT)throw Object.assign(new Error('Run this installation/check through node scripts/safe-git.mjs <command>.'),{code:'GIT_GUARD_REQUIRED'});
|
|
16
|
+
}
|
|
17
|
+
const forbidden=/(?:^|[\s/"'])git-credential-osxkeychain(?:$|[\s"'])|(?:^|[\s"'])credential-osxkeychain(?:$|[\s"'])/u;
|
|
18
|
+
/** Internal worker port: the parent and its IPC worker can independently remove this one fresh trace. */
|
|
19
|
+
export function observeGitTrace(directory){
|
|
20
|
+
const trace=join(directory,'events.jsonl'),fd=openSync(trace,'r'),buffer=Buffer.alloc(64*1024);
|
|
21
|
+
let failure,position=0,pending='',closed=false;
|
|
22
|
+
return {check(final=false){
|
|
23
|
+
if(failure)throw failure;
|
|
24
|
+
let count;while((count=readSync(fd,buffer,0,buffer.length,position))>0){position+=count;pending+=buffer.toString('utf8',0,count);}
|
|
25
|
+
const lines=pending.split('\n');pending=lines.pop();if(final&&pending)lines.push(pending);
|
|
26
|
+
for(const line of lines){
|
|
27
|
+
let event;try{event=JSON.parse(line);}catch{throw Object.assign(new Error('Invalid Git credential trace.'),{code:'GIT_TRACE_INVALID'});}
|
|
28
|
+
if(['child_start','exec'].includes(event.event)&&forbidden.test((event.argv??[]).join(' '))){failure=Object.assign(new Error('Forbidden macOS keychain helper was invoked; operation failed.'),{code:'GIT_KEYCHAIN_FORBIDDEN'});throw failure;}
|
|
29
|
+
}
|
|
30
|
+
},close(){if(closed)return;closed=true;try{closeSync(fd);}finally{rmSync(directory,{recursive:true,force:true});}}};
|
|
31
|
+
}
|
|
32
|
+
export function guardNodeBinary({workerNode,versions=process.versions,execPath=process.execPath}={}){
|
|
33
|
+
if(workerNode!==undefined)return typeof workerNode==='string'&&isAbsolute(workerNode)?workerNode:undefined;
|
|
34
|
+
return versions.electron===undefined&&isAbsolute(execPath)?execPath:undefined;
|
|
35
|
+
}
|
|
36
|
+
/** The detached worker owns the command group. IPC disconnect also cleans it if its caller dies abruptly. */
|
|
37
|
+
export async function spawnGitSafe(command,args,options={}){
|
|
38
|
+
let directory,worker,deadline,abortFallback,message,stdout='',stderr='';
|
|
39
|
+
const {workerNode,signal,timeout,encoding='utf8',...childOptions}=options;
|
|
40
|
+
const nodeRuntime=guardNodeBinary({workerNode});
|
|
41
|
+
if(!nodeRuntime)throw Object.assign(new Error('An independent Node runtime is required; Electron cannot run the Git supervisor.'),{code:'GIT_GUARD_NODE_REQUIRED'});
|
|
42
|
+
const abort=()=>{if(worker?.connected)worker.send({abort:true});abortFallback??=setTimeout(()=>worker?.kill('SIGTERM'),1000);};
|
|
43
|
+
try{
|
|
44
|
+
if(signal?.aborted)throw Object.assign(new Error('Guarded command aborted.'),{name:'AbortError',code:'ABORT_ERR'});
|
|
45
|
+
const stdio=options.stdio==='inherit'?['ignore','inherit','inherit','ipc']:['ignore','pipe','pipe','ipc'];
|
|
46
|
+
worker=fork(new URL('./git-worker.js',import.meta.url),[],{cwd:options.cwd,env:safeGitEnv(options.env??process.env),execArgv:[],execPath:nodeRuntime,detached:process.platform!=='win32',stdio});
|
|
47
|
+
worker.stdout?.on('data',chunk=>{stdout=(stdout+chunk.toString(encoding)).slice(-4*1024*1024);});
|
|
48
|
+
worker.stderr?.on('data',chunk=>{stderr=(stderr+chunk.toString(encoding)).slice(-4*1024*1024);});
|
|
49
|
+
signal?.addEventListener('abort',abort,{once:true});
|
|
50
|
+
if(timeout)deadline=setTimeout(()=>{if(worker.connected)worker.send({timeout:true});},timeout);
|
|
51
|
+
const completed=new Promise((resolve,reject)=>{
|
|
52
|
+
worker.once('error',reject);worker.on('message',value=>{if(value.directory)directory=value.directory;else message=value;});worker.once('close',()=>resolve());
|
|
53
|
+
});
|
|
54
|
+
worker.send({command,args,options:{...childOptions,env:safeGitEnv(options.env??process.env)}});
|
|
55
|
+
await completed;
|
|
56
|
+
if(message?.error)throw Object.assign(new Error('Guarded command failed.'),message.error);
|
|
57
|
+
if(!message?.result)throw Object.assign(new Error('Git monitor exited without a result.'),{code:'GIT_GUARD_FAILED'});
|
|
58
|
+
return {...message.result,stdout,stderr};
|
|
59
|
+
}finally{
|
|
60
|
+
clearTimeout(deadline);clearTimeout(abortFallback);signal?.removeEventListener('abort',abort);
|
|
61
|
+
if(worker?.connected)worker.disconnect();
|
|
62
|
+
if(directory)rmSync(directory,{recursive:true,force:true});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Internal IPC supervisor. The parent never detaches a package manager directly.
|
|
2
|
+
import {spawn,spawnSync} from 'node:child_process';
|
|
3
|
+
import {existsSync,mkdtempSync,writeFileSync,rmSync,realpathSync} from 'node:fs';
|
|
4
|
+
import {tmpdir} from 'node:os';
|
|
5
|
+
import {dirname,join,basename} from 'node:path';
|
|
6
|
+
import {observeGitTrace} from './git-process.js';
|
|
7
|
+
let monitor,directory,timer,done=false,started=false;
|
|
8
|
+
function finish(payload){
|
|
9
|
+
if(done)return;done=true;clearInterval(timer);
|
|
10
|
+
try{monitor?.check(true);}catch(error){payload={error:{code:error.code??'GIT_TRACE_INVALID'}};}
|
|
11
|
+
try{if(monitor)monitor.close();else if(directory)rmSync(directory,{recursive:true,force:true});}catch{payload={error:{code:'GIT_TRACE_CLEANUP_FAILED'}};}
|
|
12
|
+
const end=()=>{
|
|
13
|
+
if(process.platform==='win32'){spawnSync('taskkill',['/pid',String(process.pid),'/T','/F'],{stdio:'ignore'});process.exit(1);}
|
|
14
|
+
else process.kill(-process.pid,'SIGKILL');
|
|
15
|
+
};
|
|
16
|
+
if(process.connected)process.send(payload,end);else end();
|
|
17
|
+
}
|
|
18
|
+
function abort(code='ABORT_ERR'){finish({error:{code,...(code==='ABORT_ERR'?{name:'AbortError'}:{})}});}
|
|
19
|
+
process.once('disconnect',()=>abort());
|
|
20
|
+
process.once('SIGTERM',()=>abort());process.once('SIGINT',()=>abort());
|
|
21
|
+
if(!process.connected)process.exit(1);
|
|
22
|
+
process.on('message',request=>{
|
|
23
|
+
if(request.abort)return abort();if(request.timeout)return abort('ETIMEDOUT');
|
|
24
|
+
if(started)return;started=true;
|
|
25
|
+
try{
|
|
26
|
+
let {command,args,options}=request;
|
|
27
|
+
// No caller-owned trace exists before this IPC worker can clean on disconnect.
|
|
28
|
+
directory=mkdtempSync(join(realpathSync(options.env.TMPDIR??tmpdir()),'hm-git-guard-'));
|
|
29
|
+
writeFileSync(join(directory,'events.jsonl'),'',{mode:0o600});monitor=observeGitTrace(directory);
|
|
30
|
+
options.env={...options.env,GIT_TRACE2_EVENT:join(directory,'events.jsonl'),GIT_TRACE_REDACT:'1'};
|
|
31
|
+
if(!process.connected)return abort();
|
|
32
|
+
process.send({directory},error=>{if(error)abort();});
|
|
33
|
+
// Native Windows .cmd shims cannot be executed by spawn without a shell.
|
|
34
|
+
// Resolve the normal npm/global pnpm JS entry and keep argument arrays intact.
|
|
35
|
+
const manager=basename(command).replace(/\.cmd$/i,'').toLowerCase();
|
|
36
|
+
if(process.platform==='win32'&&['npm','npx','pnpm'].includes(manager)){
|
|
37
|
+
const pkg=manager==='pnpm'?'pnpm':'npm',entry=manager==='pnpm'?'pnpm.cjs':manager+'-cli.js';
|
|
38
|
+
const supplied=options.env.npm_execpath;
|
|
39
|
+
const candidates=[...(supplied&&basename(supplied)===entry?[supplied]:[]),...[dirname(process.execPath),...(options.env.PATH??options.env.Path??'').split(';')].map(base=>join(base,'node_modules',pkg,'bin',entry))];
|
|
40
|
+
const cli=candidates.find(path=>existsSync(path));if(!cli)throw Object.assign(new Error('Package manager JS entry unavailable.'),{code:'PACKAGE_MANAGER_UNAVAILABLE'});
|
|
41
|
+
command=process.execPath;args=[cli,...args];
|
|
42
|
+
}
|
|
43
|
+
const {input,...spawnOptions}=options;
|
|
44
|
+
const child=spawn(command,args,{...spawnOptions,stdio:[input===undefined?'ignore':'pipe','inherit','inherit'],detached:false});
|
|
45
|
+
if(input!==undefined){child.stdin.on('error',()=>{});child.stdin.end(input);}
|
|
46
|
+
child.once('error',error=>finish({error:{code:error.code??'SPAWN_FAILED'}}));
|
|
47
|
+
child.once('close',(code,signal)=>finish({result:{code,status:code,signal}}));
|
|
48
|
+
timer=setInterval(()=>{try{monitor.check();}catch(error){finish({error:{code:error.code??'GIT_TRACE_INVALID'}});}},25);
|
|
49
|
+
}catch(error){finish({error:{code:error.code??'GIT_GUARD_FAILED'}});}
|
|
50
|
+
});
|
package/dist/index.d.ts
CHANGED
|
@@ -47,8 +47,8 @@ export interface AppListing {
|
|
|
47
47
|
}
|
|
48
48
|
export interface EventPage { events:HostEvent[]; sequence:number; resetRequired:boolean; }
|
|
49
49
|
export interface HostOptions {
|
|
50
|
-
store:SnapshotStore; dataRoot:string; parentOrigin:string; leaseTtlMs?:number; sweepIntervalMs?:number;
|
|
51
|
-
nodeBinary?:string; clock?:()=>number; checkpoint?:(point:string,details:Record<string,unknown>)=>Awaitable<void>; credentialResolver?:CredentialResolver|null;
|
|
50
|
+
store:SnapshotStore; dataRoot:string; parentOrigin:string; frameAncestors?:string[]; leaseTtlMs?:number; sweepIntervalMs?:number;
|
|
51
|
+
nodeBinary?:string; nodeBinaryProvider?:()=>string|undefined; clock?:()=>number; checkpoint?:(point:string,details:Record<string,unknown>)=>Awaitable<void>; credentialResolver?:CredentialResolver|null;
|
|
52
52
|
runtimeLedgerReader?:(root:string)=>Awaitable<{schema:1;items:Record<string,{version:string}>}>;
|
|
53
53
|
}
|
|
54
54
|
export class AppHost {
|
|
@@ -66,6 +66,7 @@ export class AppHost {
|
|
|
66
66
|
open(input:OpenRequest,principalId?:string):Promise<OpenReceipt>; start(input:OpenRequest,principalId?:string):Promise<OpenReceipt>;
|
|
67
67
|
resume(input:LeaseRequest,principalId?:string,options?:{waitForReady?:boolean}):Promise<OpenReceipt>;
|
|
68
68
|
recoverView(input:RecoverRequest,principalId?:string):Promise<OpenReceipt>;
|
|
69
|
+
authorizeBootstrap(input:LeaseRequest & {gatewayOrigin:string;challenge:string},principalId?:string):Promise<{authorization:string}>;
|
|
69
70
|
heartbeat(input:LeaseRequest,principalId?:string):Promise<ViewLease>;
|
|
70
71
|
close(input:LeaseRequest,principalId?:string):Promise<{instanceId:string;alreadyClosed:boolean}>;
|
|
71
72
|
stop(instanceId:string,options?:{confirm?:boolean},principalId?:string):Promise<InstanceRecord>; stopAll():Promise<void>;
|
|
@@ -88,7 +89,7 @@ export class DshDomainSnapshotStore implements SnapshotStore {
|
|
|
88
89
|
}
|
|
89
90
|
export interface AuthenticatedSubject { principalId:string; [key:string]:unknown; }
|
|
90
91
|
export interface HttpOptions<Request=unknown> {
|
|
91
|
-
parentOrigin:string; authenticate(request:Request):Awaitable<AuthenticatedSubject|null>;
|
|
92
|
+
parentOrigin:string; transportOrigin?:string; authenticate(request:Request):Awaitable<AuthenticatedSubject|null>;
|
|
92
93
|
authorize(subject:AuthenticatedSubject,path:string,input:unknown):Awaitable<boolean>;
|
|
93
94
|
}
|
|
94
95
|
export function createHttpHandler<Request=unknown,Response=unknown>(host:AppHost,options:HttpOptions<Request>):(request:Request,response:Response)=>Promise<void>;
|
|
@@ -96,20 +97,20 @@ export class FixedGateway {
|
|
|
96
97
|
constructor(options:{upstream:string;parentOrigin:string;isLeaseActive:(viewKey:string)=>boolean;cookieAllowlist?:string[];allowAppAuthorization?:boolean;maxUploadBytes?:number;frameAncestors?:string[];
|
|
97
98
|
/** rc.27: called once per forwarded request the upstream answered with anything but 401/403 (and per accepted WebSocket upgrade). */
|
|
98
99
|
onForward?:()=>void});
|
|
99
|
-
origin:string; start():Promise<string>; issue(viewKey:string):string; close():Promise<void>;
|
|
100
|
+
origin:string; dataOrigin?:string; start():Promise<string>; issue(viewKey:string):string; issueAsync(viewKey:string):Promise<string>; authorizeChallenge(viewKey:string,gatewayOrigin:string,challenge:string):{authorization:string}; revokeInactive():void; close():Promise<void>;
|
|
100
101
|
}
|
|
101
102
|
export function rewriteCsp(value:string,parentOrigin:string):string;
|
|
102
103
|
export function embeddingHeaders(rawHeaders:string[],parentOrigin:string):string[];
|
|
103
104
|
export function validateDefinition(input:AppDefinition):AppDefinition;
|
|
104
|
-
/**
|
|
105
|
+
/** Canonical collecting plugin identity; runtime/transport observations never form business Usage facts. */
|
|
105
106
|
export const SOURCE_PLUGIN:'@hanamesh/dsh-app-host';
|
|
106
107
|
export function hourBucket(ms:number):string;
|
|
107
|
-
/**
|
|
108
|
+
/** Independent Router diagnostic receipt: routed provider/model and forwarded-request count; not a successful-business fact. */
|
|
108
109
|
export interface UsageReceipt { providerId:string; model:string|null; count:number; }
|
|
109
110
|
export interface UsageRecordInput { hanaRef:string; action:'open'|'use'; occurredAt?:string; idempotencyKey:string; sourcePlugin:string; sourceHanaRef?:string; targetRef?:string; receipt?:UsageReceipt; }
|
|
110
111
|
export interface UsageRecordResult { disposition:'recorded'|'duplicate'|'withheld'|'rejected'; eventId?:string; code?:string; }
|
|
111
112
|
export interface UsageSeat { record(input:UsageRecordInput):Awaitable<UsageRecordResult>; }
|
|
112
|
-
/**
|
|
113
|
+
/** Router diagnostic ledger (hanamesh_router_receipts). Pending/reported are legacy ledger state, not Usage confirmation. */
|
|
113
114
|
export interface ReceiptRoute { providerId:string; model:string|null; }
|
|
114
115
|
export interface ReceiptRow { appId:string; providerId:string|null; model:string|null; hour:string; count:number; injections:number; firstAt:string; lastAt:string; reported:boolean; }
|
|
115
116
|
export interface ReceiptLedger {
|
|
@@ -122,6 +123,7 @@ export interface ReceiptLedger {
|
|
|
122
123
|
}
|
|
123
124
|
export function createReceiptLedger(options?:{domain?:{global:{get():unknown;set(value:unknown):Promise<void>}};clock?:()=>number;persistDelayMs?:number;maxItems?:number;retentionMs?:number}):ReceiptLedger;
|
|
124
125
|
export function normalizeRoute(route:unknown):ReceiptRoute|null;
|
|
126
|
+
/** No verified original fact source is connected: logs USAGE_FACT_UNAVAILABLE and persists Router diagnostics only. Legacy scheduling options remain accepted; no reporting timer runs. flush/settle do not observe Usage completion. */
|
|
125
127
|
export function createUsageEvidence(options:{host:AppHost;seat:()=>UsageSeat|unknown;receipts?:ReceiptLedger;logger?:{debug?:(...args:unknown[])=>void};clock?:()=>number;sweepIntervalMs?:number}):{
|
|
126
128
|
flush(options?:{appId?:string;includeCurrent?:boolean}):Promise<void>; settle():Promise<void>; receipts:ReceiptLedger; close():void;
|
|
127
129
|
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": "1.0.0",
|
|
3
|
+
"revision": "install-target-contract-fixture-1",
|
|
4
|
+
"items": [
|
|
5
|
+
{
|
|
6
|
+
"id": "fixture.install-target.plugin",
|
|
7
|
+
"name": "@hanamesh-fixture/install-target-plugin",
|
|
8
|
+
"displayName": "测试目录 · 安装目标插件",
|
|
9
|
+
"summary": "Install-target contract fixture (test catalog, not a real product).",
|
|
10
|
+
"latestVersion": "1.2.3",
|
|
11
|
+
"categories": [
|
|
12
|
+
"utility"
|
|
13
|
+
],
|
|
14
|
+
"package": {
|
|
15
|
+
"registry": "npm",
|
|
16
|
+
"name": "@hanamesh-fixture/install-target-plugin"
|
|
17
|
+
},
|
|
18
|
+
"publisher": {
|
|
19
|
+
"name": "HanaMesh fixture"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"id": "fixture.install-target.app",
|
|
24
|
+
"name": "@hanamesh-fixture/install-target-app",
|
|
25
|
+
"displayName": "测试目录 · 安装目标应用",
|
|
26
|
+
"summary": "Install-target contract fixture application (test catalog).",
|
|
27
|
+
"latestVersion": "0.4.0",
|
|
28
|
+
"categories": [
|
|
29
|
+
"hanamesh-app"
|
|
30
|
+
],
|
|
31
|
+
"package": {
|
|
32
|
+
"registry": "npm",
|
|
33
|
+
"name": "@hanamesh-fixture/install-target-app"
|
|
34
|
+
},
|
|
35
|
+
"publisher": {
|
|
36
|
+
"name": "HanaMesh fixture"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"id": "fixture.install-target.repository-only",
|
|
41
|
+
"name": "install-target-repository-only",
|
|
42
|
+
"displayName": "测试目录 · 仅源码",
|
|
43
|
+
"summary": "Repository-only listing; the market cannot install it.",
|
|
44
|
+
"categories": [
|
|
45
|
+
"utility"
|
|
46
|
+
],
|
|
47
|
+
"repository": {
|
|
48
|
+
"url": "https://example.com/repository"
|
|
49
|
+
},
|
|
50
|
+
"publisher": {
|
|
51
|
+
"name": "HanaMesh fixture"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
],
|
|
55
|
+
"page": {
|
|
56
|
+
"total": 3
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
{
|
|
2
|
+
"contract": "hanamesh.install-target",
|
|
3
|
+
"contractVersion": "1",
|
|
4
|
+
"workspace": "http://127.0.0.1:43123/workspace/fixture-session?token=fixture-token#previous",
|
|
5
|
+
"targets": [
|
|
6
|
+
{
|
|
7
|
+
"id": "CN01",
|
|
8
|
+
"note": "both identifiers (current Desktop link shape)",
|
|
9
|
+
"target": {
|
|
10
|
+
"itemId": "fixture.install-target.plugin",
|
|
11
|
+
"packageName": "@hanamesh-fixture/install-target-plugin"
|
|
12
|
+
},
|
|
13
|
+
"expect": "navigate",
|
|
14
|
+
"resolves": "fixture.install-target.plugin"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"id": "CN02",
|
|
18
|
+
"note": "scoped application",
|
|
19
|
+
"target": {
|
|
20
|
+
"itemId": "fixture.install-target.app",
|
|
21
|
+
"packageName": "@hanamesh-fixture/install-target-app"
|
|
22
|
+
},
|
|
23
|
+
"expect": "navigate",
|
|
24
|
+
"resolves": "fixture.install-target.app"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"id": "CN03",
|
|
28
|
+
"note": "package name only; a consumer may require both identifiers",
|
|
29
|
+
"target": {
|
|
30
|
+
"packageName": "@hanamesh-fixture/install-target-plugin"
|
|
31
|
+
},
|
|
32
|
+
"expect": "navigate",
|
|
33
|
+
"optional": true,
|
|
34
|
+
"resolves": "fixture.install-target.plugin"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"id": "CN04",
|
|
38
|
+
"note": "item id only; a consumer may require both identifiers",
|
|
39
|
+
"target": {
|
|
40
|
+
"itemId": "fixture.install-target.plugin"
|
|
41
|
+
},
|
|
42
|
+
"expect": "navigate",
|
|
43
|
+
"optional": true,
|
|
44
|
+
"resolves": "fixture.install-target.plugin"
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"id": "CR01",
|
|
48
|
+
"note": "caller-supplied version",
|
|
49
|
+
"target": {
|
|
50
|
+
"itemId": "fixture.install-target.plugin",
|
|
51
|
+
"packageName": "@hanamesh-fixture/install-target-plugin",
|
|
52
|
+
"version": "9.0.0"
|
|
53
|
+
},
|
|
54
|
+
"expect": "refuse"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"id": "CR02",
|
|
58
|
+
"note": "caller-supplied download location",
|
|
59
|
+
"target": {
|
|
60
|
+
"itemId": "fixture.install-target.plugin",
|
|
61
|
+
"packageName": "@hanamesh-fixture/install-target-plugin",
|
|
62
|
+
"url": "https://evil.example/pkg.tgz"
|
|
63
|
+
},
|
|
64
|
+
"expect": "refuse"
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"id": "CR03",
|
|
68
|
+
"note": "package name grammar",
|
|
69
|
+
"target": {
|
|
70
|
+
"itemId": "fixture.install-target.plugin",
|
|
71
|
+
"packageName": "../escape"
|
|
72
|
+
},
|
|
73
|
+
"expect": "refuse"
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"id": "CR04",
|
|
77
|
+
"note": "item id longer than 160",
|
|
78
|
+
"target": {
|
|
79
|
+
"itemId": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
80
|
+
"packageName": "@hanamesh-fixture/install-target-plugin"
|
|
81
|
+
},
|
|
82
|
+
"expect": "refuse"
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"id": "CR05",
|
|
86
|
+
"note": "control character",
|
|
87
|
+
"target": {
|
|
88
|
+
"itemId": "fixture\u0007id",
|
|
89
|
+
"packageName": "@hanamesh-fixture/install-target-plugin"
|
|
90
|
+
},
|
|
91
|
+
"expect": "refuse"
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
"id": "CR06",
|
|
95
|
+
"note": "no identifier",
|
|
96
|
+
"target": {},
|
|
97
|
+
"expect": "refuse"
|
|
98
|
+
}
|
|
99
|
+
],
|
|
100
|
+
"handshake": [
|
|
101
|
+
{
|
|
102
|
+
"id": "CH01",
|
|
103
|
+
"note": "provider implementing v1",
|
|
104
|
+
"provider": {
|
|
105
|
+
"contract": "hanamesh.install-target",
|
|
106
|
+
"contractVersion": "1",
|
|
107
|
+
"supported": [
|
|
108
|
+
"1"
|
|
109
|
+
]
|
|
110
|
+
},
|
|
111
|
+
"expect": "accept"
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
"id": "CH02",
|
|
115
|
+
"note": "provider implementing only a future version",
|
|
116
|
+
"provider": {
|
|
117
|
+
"contract": "hanamesh.install-target",
|
|
118
|
+
"contractVersion": "2",
|
|
119
|
+
"supported": [
|
|
120
|
+
"2"
|
|
121
|
+
]
|
|
122
|
+
},
|
|
123
|
+
"expect": "refuse"
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
"id": "CH03",
|
|
127
|
+
"note": "provider without a declaration (0.2.0-rc.7 and older)",
|
|
128
|
+
"provider": null,
|
|
129
|
+
"expect": "refuse"
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
"id": "CH04",
|
|
133
|
+
"note": "declaration for a different contract",
|
|
134
|
+
"provider": {
|
|
135
|
+
"contract": "hanamesh.something-else",
|
|
136
|
+
"contractVersion": "1",
|
|
137
|
+
"supported": [
|
|
138
|
+
"1"
|
|
139
|
+
]
|
|
140
|
+
},
|
|
141
|
+
"expect": "refuse"
|
|
142
|
+
}
|
|
143
|
+
],
|
|
144
|
+
"workspaces": [
|
|
145
|
+
"http://127.0.0.1:43123/workspace/fixture-session?token=fixture-token#previous",
|
|
146
|
+
"http://localhost:43123/workspace/fixture-session?token=fixture-token#previous"
|
|
147
|
+
]
|
|
148
|
+
}
|