@aipoch/open-science 0.0.0-stage → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLI.md +814 -0
- package/LICENSE +201 -0
- package/README.md +284 -2
- package/appimage-launcher.mjs +90 -0
- package/browser-import.d.ts +66 -0
- package/browser-import.mjs +389 -0
- package/cli.mjs +1845 -0
- package/codex-login.mjs +298 -0
- package/config-root.mjs +65 -0
- package/index.d.ts +677 -0
- package/index.mjs +761 -0
- package/installation-startup.cjs +205 -0
- package/locate-app.mjs +95 -0
- package/locate-backend.mjs +63 -0
- package/package.json +45 -4
- package/runtime-control.mjs +51 -0
- package/runtime-package.mjs +19 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/explicit-function-return-type -- Browser ESM; public types are in browser-import.d.ts. */
|
|
2
|
+
// Browser-only entry point: deliberately has no Node imports, daemon token, or remote downloader.
|
|
3
|
+
const MAX_BYTES = 2 * 1024 ** 3
|
|
4
|
+
const CHUNK_BYTES = 4 * 1024 ** 2
|
|
5
|
+
const hex = (bytes) =>
|
|
6
|
+
Array.from(new Uint8Array(bytes), (byte) => byte.toString(16).padStart(2, '0')).join('')
|
|
7
|
+
const randomHex = () => hex(crypto.getRandomValues(new Uint8Array(32)))
|
|
8
|
+
const wait = (ms, signal) =>
|
|
9
|
+
new Promise((resolve, reject) => {
|
|
10
|
+
signal?.throwIfAborted()
|
|
11
|
+
const abort = () => {
|
|
12
|
+
clearTimeout(timer)
|
|
13
|
+
reject(signal.reason)
|
|
14
|
+
}
|
|
15
|
+
const timer = setTimeout(() => {
|
|
16
|
+
signal?.removeEventListener('abort', abort)
|
|
17
|
+
resolve()
|
|
18
|
+
}, ms)
|
|
19
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
/** Stable codes let websites show recovery guidance without parsing English messages. */
|
|
23
|
+
export class BrowserImportError extends Error {
|
|
24
|
+
constructor(code, message) {
|
|
25
|
+
super(message)
|
|
26
|
+
this.name = 'BrowserImportError'
|
|
27
|
+
this.code = code
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Request app launch synchronously from a user click; return does not prove OS delivery. */
|
|
32
|
+
export function launchOpenScience() {
|
|
33
|
+
if (typeof globalThis.location?.assign !== 'function')
|
|
34
|
+
throw new BrowserImportError(
|
|
35
|
+
'unsupported-browser',
|
|
36
|
+
'This environment cannot open Open-Science.'
|
|
37
|
+
)
|
|
38
|
+
try {
|
|
39
|
+
globalThis.location.assign('open-science://open')
|
|
40
|
+
} catch {
|
|
41
|
+
throw new BrowserImportError('launch-failed', 'Could not request opening Open-Science.')
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const supported = () =>
|
|
46
|
+
typeof Blob === 'function' &&
|
|
47
|
+
typeof fetch === 'function' &&
|
|
48
|
+
!!globalThis.crypto?.subtle &&
|
|
49
|
+
typeof globalThis.AbortSignal?.any === 'function'
|
|
50
|
+
const validOrigin = (origin) => {
|
|
51
|
+
const site = new URL(origin)
|
|
52
|
+
if (
|
|
53
|
+
site.origin !== origin ||
|
|
54
|
+
(site.protocol !== 'https:' &&
|
|
55
|
+
!(site.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(site.hostname)))
|
|
56
|
+
)
|
|
57
|
+
throw new Error('Browser import requires an HTTPS website or loopback development origin.')
|
|
58
|
+
if (globalThis.location && globalThis.location.origin !== origin)
|
|
59
|
+
throw new Error('The import origin must match this page.')
|
|
60
|
+
return origin
|
|
61
|
+
}
|
|
62
|
+
// Explicitly dispose timers after every request/deadline; response body reads share the budget.
|
|
63
|
+
const bounded = async (signal, timeoutMs, work) => {
|
|
64
|
+
const controller = new AbortController()
|
|
65
|
+
const timer = setTimeout(
|
|
66
|
+
() =>
|
|
67
|
+
controller.abort(
|
|
68
|
+
new BrowserImportError('timeout', 'Open-Science did not become ready in time.')
|
|
69
|
+
),
|
|
70
|
+
timeoutMs
|
|
71
|
+
)
|
|
72
|
+
const combined = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal
|
|
73
|
+
try {
|
|
74
|
+
combined.throwIfAborted()
|
|
75
|
+
return await work(combined)
|
|
76
|
+
} finally {
|
|
77
|
+
clearTimeout(timer)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const discoveryRequest = async (options) => {
|
|
81
|
+
validOrigin(options.origin ?? globalThis.location?.origin)
|
|
82
|
+
const port = options.port ?? 49151
|
|
83
|
+
const timeoutMs = options.timeoutMs ?? 2000
|
|
84
|
+
if (
|
|
85
|
+
!Number.isInteger(port) ||
|
|
86
|
+
port < 1024 ||
|
|
87
|
+
port > 65535 ||
|
|
88
|
+
!Number.isFinite(timeoutMs) ||
|
|
89
|
+
timeoutMs <= 0 ||
|
|
90
|
+
timeoutMs > 300_000
|
|
91
|
+
)
|
|
92
|
+
throw new Error('Invalid discovery options.')
|
|
93
|
+
return bounded(options.signal, timeoutMs, async (signal) => {
|
|
94
|
+
const response = await fetch(`http://127.0.0.1:${port}/v1/browser-import/capabilities`, {
|
|
95
|
+
method: 'GET',
|
|
96
|
+
signal,
|
|
97
|
+
mode: 'cors',
|
|
98
|
+
credentials: 'omit',
|
|
99
|
+
cache: 'no-store',
|
|
100
|
+
redirect: 'error',
|
|
101
|
+
targetAddressSpace: 'loopback'
|
|
102
|
+
})
|
|
103
|
+
const value = await response.json()
|
|
104
|
+
if (
|
|
105
|
+
!response.ok ||
|
|
106
|
+
!value ||
|
|
107
|
+
value.service !== 'open-science-browser-import' ||
|
|
108
|
+
value.version !== 1
|
|
109
|
+
)
|
|
110
|
+
return { status: 'incompatible' }
|
|
111
|
+
return { status: 'available' }
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
const discover = async (options) => {
|
|
115
|
+
if (!supported()) return { status: 'unsupported-browser' }
|
|
116
|
+
try {
|
|
117
|
+
return await discoveryRequest(options)
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (options.signal?.aborted) return { status: 'aborted' }
|
|
120
|
+
if (error.code === 'timeout' || error.name === 'TimeoutError') return { status: 'timeout' }
|
|
121
|
+
if (error.name === 'NotAllowedError') return { status: 'unavailable' }
|
|
122
|
+
if (error instanceof SyntaxError) return { status: 'incompatible' }
|
|
123
|
+
// Browsers deliberately conflate connection refusal, CORS and local-network denial.
|
|
124
|
+
if (error instanceof TypeError) return { status: 'unavailable' }
|
|
125
|
+
throw error
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** Advisory discovery only; the secret-bound handshake still authenticates every transfer. */
|
|
129
|
+
export const checkBrowserImportAvailability = (options = {}) => discover(options)
|
|
130
|
+
/** Prepare a transfer from bytes already downloaded by the website. Open url from a user click,
|
|
131
|
+
* then call send(). Its result acknowledges local receipt, not the user's eventual import decision.
|
|
132
|
+
*/
|
|
133
|
+
export function prepareBrowserImport(file, options = {}) {
|
|
134
|
+
const origin = options.origin ?? globalThis.location?.origin
|
|
135
|
+
if (!supported())
|
|
136
|
+
throw new BrowserImportError(
|
|
137
|
+
'unsupported-browser',
|
|
138
|
+
'This browser cannot transfer packages to Open-Science.'
|
|
139
|
+
)
|
|
140
|
+
validOrigin(origin)
|
|
141
|
+
const filename = options.filename ?? file?.name
|
|
142
|
+
const port = options.port ?? 49152 + (crypto.getRandomValues(new Uint16Array(1))[0] % 16384)
|
|
143
|
+
if (!(file instanceof Blob) || file.size <= 0 || file.size > MAX_BYTES)
|
|
144
|
+
throw new Error('Choose a nonempty .science package no larger than 2 GiB.')
|
|
145
|
+
if (
|
|
146
|
+
typeof filename !== 'string' ||
|
|
147
|
+
filename.length > 160 ||
|
|
148
|
+
new TextEncoder().encode(filename).byteLength > 255 ||
|
|
149
|
+
/^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/iu.test(filename) ||
|
|
150
|
+
!/\.science$/i.test(filename) ||
|
|
151
|
+
// eslint-disable-next-line no-control-regex -- Reject control characters in external filenames.
|
|
152
|
+
/[\x00-\x1f\x7f/\\:*?"<>|\u202a-\u202e\u2066-\u2069]/u.test(filename) ||
|
|
153
|
+
filename.startsWith('.') ||
|
|
154
|
+
/[. ]$/.test(filename)
|
|
155
|
+
)
|
|
156
|
+
throw new Error('Invalid .science filename.')
|
|
157
|
+
if (!Number.isInteger(port) || port < 49152 || port > 65535)
|
|
158
|
+
throw new Error('Invalid loopback port.')
|
|
159
|
+
const secret = randomHex()
|
|
160
|
+
const url = `open-science://import?${new URLSearchParams({ version: '1', origin, secret, port: String(port), name: filename, size: String(file.size) })}`
|
|
161
|
+
const base = `http://127.0.0.1:${port}/v1/import`
|
|
162
|
+
const keyBytes = new Uint8Array(secret.match(/../g).map((part) => parseInt(part, 16)))
|
|
163
|
+
const authKey = crypto.subtle.importKey(
|
|
164
|
+
'raw',
|
|
165
|
+
keyBytes,
|
|
166
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
167
|
+
false,
|
|
168
|
+
['sign']
|
|
169
|
+
)
|
|
170
|
+
const sign = async (message) =>
|
|
171
|
+
hex(await crypto.subtle.sign('HMAC', await authKey, new TextEncoder().encode(message)))
|
|
172
|
+
const authorize = async (method, path, digest = '') => ({
|
|
173
|
+
Authorization: `HMAC ${await sign(`${method}\n/v1/import${path}\n${digest}`)}`
|
|
174
|
+
})
|
|
175
|
+
const cipherKey = crypto.subtle
|
|
176
|
+
.digest('SHA-256', new TextEncoder().encode(`open-science-encryption-v1:${secret}`))
|
|
177
|
+
.then((bytes) => crypto.subtle.importKey('raw', bytes, 'AES-GCM', false, ['encrypt']))
|
|
178
|
+
let sending = false
|
|
179
|
+
let received = false
|
|
180
|
+
let cancelled
|
|
181
|
+
|
|
182
|
+
return Object.freeze({
|
|
183
|
+
url,
|
|
184
|
+
async send({
|
|
185
|
+
signal,
|
|
186
|
+
onProgress,
|
|
187
|
+
onPhase,
|
|
188
|
+
readinessMode = 'attempts',
|
|
189
|
+
readyTimeoutMs = 120_000,
|
|
190
|
+
readinessAttempts = 5,
|
|
191
|
+
attemptTimeoutMs = 2000
|
|
192
|
+
} = {}) {
|
|
193
|
+
if (!['attempts', 'wait-for-launch'].includes(readinessMode))
|
|
194
|
+
throw new Error('Invalid readiness mode.')
|
|
195
|
+
const waitingForLaunch = readinessMode === 'wait-for-launch'
|
|
196
|
+
if (!Number.isFinite(readyTimeoutMs) || readyTimeoutMs <= 0)
|
|
197
|
+
throw new Error('Invalid receiver timeout.')
|
|
198
|
+
if (
|
|
199
|
+
(!waitingForLaunch &&
|
|
200
|
+
(!Number.isInteger(readinessAttempts) ||
|
|
201
|
+
readinessAttempts < 1 ||
|
|
202
|
+
readinessAttempts > 5)) ||
|
|
203
|
+
!Number.isFinite(attemptTimeoutMs) ||
|
|
204
|
+
attemptTimeoutMs <= 0 ||
|
|
205
|
+
attemptTimeoutMs > 30_000
|
|
206
|
+
)
|
|
207
|
+
throw new Error('Invalid readiness retry options.')
|
|
208
|
+
if (sending) throw new Error('This browser transfer is already running.')
|
|
209
|
+
if (cancelled) throw cancelled
|
|
210
|
+
if (received) return { received: true }
|
|
211
|
+
signal?.throwIfAborted()
|
|
212
|
+
sending = true
|
|
213
|
+
let verified = false
|
|
214
|
+
const request = async (path, init = {}) => {
|
|
215
|
+
const response = await fetch(base + path, {
|
|
216
|
+
mode: 'cors',
|
|
217
|
+
credentials: 'omit',
|
|
218
|
+
cache: 'no-store',
|
|
219
|
+
redirect: 'error',
|
|
220
|
+
targetAddressSpace: 'loopback',
|
|
221
|
+
...init,
|
|
222
|
+
signal: AbortSignal.any(
|
|
223
|
+
[signal, init.signal ?? AbortSignal.timeout(30_000)].filter(Boolean)
|
|
224
|
+
)
|
|
225
|
+
})
|
|
226
|
+
if (response.status === 410) {
|
|
227
|
+
const value = await response.json().catch(() => {
|
|
228
|
+
throw new BrowserImportError(
|
|
229
|
+
'incompatible',
|
|
230
|
+
'Could not verify the local receiver identity.'
|
|
231
|
+
)
|
|
232
|
+
})
|
|
233
|
+
if (value?.code !== 'cancelled' || value.proof !== (await sign('cancelled')))
|
|
234
|
+
throw new BrowserImportError(
|
|
235
|
+
'incompatible',
|
|
236
|
+
'Could not verify the local receiver identity.'
|
|
237
|
+
)
|
|
238
|
+
cancelled = new BrowserImportError(
|
|
239
|
+
'cancelled',
|
|
240
|
+
'The transfer was cancelled in Open-Science. Prepare a new transfer to try again.'
|
|
241
|
+
)
|
|
242
|
+
throw cancelled
|
|
243
|
+
}
|
|
244
|
+
return response
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
onPhase?.('waiting-for-receiver')
|
|
248
|
+
// Browser protocol prompts have no observable completion event. Only this secret's proof
|
|
249
|
+
// ends launch waiting; the bounded deadline covers requests, body reads and retry gaps.
|
|
250
|
+
const budget = Math.min(readyTimeoutMs, waitingForLaunch ? 120_000 : 300_000)
|
|
251
|
+
const attemptBudget = waitingForLaunch ? Math.min(attemptTimeoutMs, 2000) : attemptTimeoutMs
|
|
252
|
+
const deadline = Date.now() + budget
|
|
253
|
+
const ready = await bounded(signal, budget, async (readySignal) => {
|
|
254
|
+
for (let attempt = 0; waitingForLaunch || attempt < readinessAttempts; attempt++) {
|
|
255
|
+
readySignal.throwIfAborted()
|
|
256
|
+
try {
|
|
257
|
+
return await bounded(readySignal, attemptBudget, async (attemptSignal) => {
|
|
258
|
+
const challenge = randomHex()
|
|
259
|
+
const response = await request(`/ready?challenge=${challenge}`, {
|
|
260
|
+
signal: attemptSignal
|
|
261
|
+
})
|
|
262
|
+
const value = await response.json()
|
|
263
|
+
if (
|
|
264
|
+
!value ||
|
|
265
|
+
typeof value !== 'object' ||
|
|
266
|
+
typeof value.accepted !== 'boolean' ||
|
|
267
|
+
!Number.isSafeInteger(value.offset) ||
|
|
268
|
+
value.offset < 0 ||
|
|
269
|
+
value.offset > file.size ||
|
|
270
|
+
value.chunkBytes !== CHUNK_BYTES
|
|
271
|
+
)
|
|
272
|
+
throw new BrowserImportError(
|
|
273
|
+
'incompatible',
|
|
274
|
+
'Could not verify the local receiver identity.'
|
|
275
|
+
)
|
|
276
|
+
const expected = await sign(
|
|
277
|
+
`open-science-import-v1:${challenge}:${value.offset}:${value.accepted ? 1 : 0}`
|
|
278
|
+
)
|
|
279
|
+
attemptSignal.throwIfAborted()
|
|
280
|
+
if (Date.now() >= deadline)
|
|
281
|
+
throw new BrowserImportError(
|
|
282
|
+
'timeout',
|
|
283
|
+
'Open-Science did not become ready in time.'
|
|
284
|
+
)
|
|
285
|
+
if (!response.ok || value.proof !== expected)
|
|
286
|
+
throw new BrowserImportError(
|
|
287
|
+
'incompatible',
|
|
288
|
+
'Could not verify the local receiver identity. Prepare a new transfer and reopen Open-Science.'
|
|
289
|
+
)
|
|
290
|
+
return value
|
|
291
|
+
})
|
|
292
|
+
} catch (error) {
|
|
293
|
+
readySignal.throwIfAborted()
|
|
294
|
+
if (error.code === 'incompatible' || error.code === 'cancelled') throw error
|
|
295
|
+
if (error instanceof SyntaxError)
|
|
296
|
+
throw new BrowserImportError(
|
|
297
|
+
'incompatible',
|
|
298
|
+
'Could not verify the local receiver identity.'
|
|
299
|
+
)
|
|
300
|
+
if (waitingForLaunch || attempt + 1 < readinessAttempts) await wait(1000, readySignal)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
throw new BrowserImportError(
|
|
304
|
+
'readiness-exhausted',
|
|
305
|
+
'Open-Science did not become ready. Open the app, allow local network access, and retry.'
|
|
306
|
+
)
|
|
307
|
+
})
|
|
308
|
+
verified = true
|
|
309
|
+
if (
|
|
310
|
+
!Number.isSafeInteger(ready.offset) ||
|
|
311
|
+
ready.offset < 0 ||
|
|
312
|
+
ready.offset > file.size ||
|
|
313
|
+
ready.chunkBytes !== CHUNK_BYTES
|
|
314
|
+
)
|
|
315
|
+
throw new Error('Invalid local transfer response.')
|
|
316
|
+
if (!ready.accepted) {
|
|
317
|
+
onPhase?.('transferring')
|
|
318
|
+
for (let offset = ready.offset; offset < file.size;) {
|
|
319
|
+
signal?.throwIfAborted()
|
|
320
|
+
const bytes = await file.slice(offset, offset + CHUNK_BYTES).arrayBuffer()
|
|
321
|
+
const digest = hex(await crypto.subtle.digest('SHA-256', bytes))
|
|
322
|
+
const iv = crypto.getRandomValues(new Uint8Array(12))
|
|
323
|
+
const encrypted = await crypto.subtle.encrypt(
|
|
324
|
+
{ name: 'AES-GCM', iv, additionalData: new TextEncoder().encode(`offset:${offset}`) },
|
|
325
|
+
await cipherKey,
|
|
326
|
+
bytes
|
|
327
|
+
)
|
|
328
|
+
const body = new Uint8Array(12 + encrypted.byteLength)
|
|
329
|
+
body.set(iv)
|
|
330
|
+
body.set(new Uint8Array(encrypted), 12)
|
|
331
|
+
const chunk = await request(`/chunk?offset=${offset}`, {
|
|
332
|
+
method: 'PUT',
|
|
333
|
+
body,
|
|
334
|
+
headers: {
|
|
335
|
+
...(await authorize('PUT', `/chunk?offset=${offset}`, digest)),
|
|
336
|
+
'Content-Type': 'application/octet-stream',
|
|
337
|
+
'X-Chunk-SHA256': digest
|
|
338
|
+
}
|
|
339
|
+
})
|
|
340
|
+
if (!chunk.ok) throw new Error(`Local package transfer failed (${chunk.status}).`)
|
|
341
|
+
const result = await chunk.json()
|
|
342
|
+
if (
|
|
343
|
+
result.offset !== offset + bytes.byteLength ||
|
|
344
|
+
result.proof !== (await sign(`offset:${result.offset}`))
|
|
345
|
+
)
|
|
346
|
+
throw new Error('Invalid local transfer offset.')
|
|
347
|
+
offset = result.offset
|
|
348
|
+
onProgress?.({ sentBytes: offset, totalBytes: file.size })
|
|
349
|
+
}
|
|
350
|
+
const complete = await request('/complete', {
|
|
351
|
+
method: 'POST',
|
|
352
|
+
headers: await authorize('POST', '/complete')
|
|
353
|
+
})
|
|
354
|
+
const receipt = await complete.json()
|
|
355
|
+
if (
|
|
356
|
+
complete.status !== 202 ||
|
|
357
|
+
receipt.received !== true ||
|
|
358
|
+
receipt.proof !== (await sign(`received:${file.size}`))
|
|
359
|
+
)
|
|
360
|
+
throw new Error('Open-Science did not acknowledge the complete package.')
|
|
361
|
+
}
|
|
362
|
+
received = true
|
|
363
|
+
return { received: true }
|
|
364
|
+
} catch (error) {
|
|
365
|
+
if (verified && signal?.aborted) {
|
|
366
|
+
await fetch(base, {
|
|
367
|
+
method: 'DELETE',
|
|
368
|
+
mode: 'cors',
|
|
369
|
+
credentials: 'omit',
|
|
370
|
+
redirect: 'error',
|
|
371
|
+
headers: await authorize('DELETE', ''),
|
|
372
|
+
signal: AbortSignal.timeout(2000),
|
|
373
|
+
targetAddressSpace: 'loopback'
|
|
374
|
+
}).catch(() => undefined)
|
|
375
|
+
}
|
|
376
|
+
signal?.throwIfAborted()
|
|
377
|
+
if (error.code === 'cancelled' || error.code === 'incompatible') throw error
|
|
378
|
+
if (verified)
|
|
379
|
+
throw new BrowserImportError(
|
|
380
|
+
'transfer-failed',
|
|
381
|
+
'The local package transfer failed. Retry while the receiver is available.'
|
|
382
|
+
)
|
|
383
|
+
throw error
|
|
384
|
+
} finally {
|
|
385
|
+
sending = false
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
})
|
|
389
|
+
}
|