@astrale-os/cli 1.0.0-beta.19 → 1.0.0-beta.20
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/dist/astrale.js +249 -68
- package/dist/public/connect-core.js +143 -2
- package/dist/types/lib/update.d.ts +20 -2
- package/package.json +3 -3
- package/src/commands/__tests__/view.test.ts +49 -0
- package/src/commands/view.ts +75 -31
- package/src/lib/__tests__/update.test.ts +164 -4
- package/src/lib/__tests__/view-assets.test.ts +14 -0
- package/src/lib/__tests__/view-external-open-intent.test.ts +150 -0
- package/src/lib/__tests__/view-external-open-origins.test.ts +27 -0
- package/src/lib/__tests__/view-server.test.ts +8 -0
- package/src/lib/__tests__/view-session.test.ts +1 -0
- package/src/lib/update.ts +178 -19
- package/src/lib/view/assets.ts +11 -2
- package/src/lib/view/external-open-intent.ts +41 -0
- package/src/lib/view/external-open-origins.ts +37 -0
- package/src/lib/view/host-capabilities.ts +17 -0
- package/src/lib/view/server.ts +1 -0
- package/src/lib/view/session.ts +2 -0
- package/src/program/__tests__/program.test.ts +1 -1
- package/studio/package.json +2 -2
- package/viewer/dist/main.js +28 -28
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { ChildrenChannel, ExternalOpenRequest, Shell } from '@astrale-os/shell'
|
|
2
|
+
|
|
3
|
+
import { capabilitiesMiddleware, createIntentPipeline, createIntentRouter } from '@astrale-os/shell'
|
|
4
|
+
import { describe, expect, mock, test } from 'bun:test'
|
|
5
|
+
|
|
6
|
+
import type { ExternalOpenIntentMessage } from '../view/external-open-intent'
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
installExternalOpenIntentHandler,
|
|
10
|
+
openExternalBrowserWindow,
|
|
11
|
+
} from '../view/external-open-intent'
|
|
12
|
+
import { viewHostCapabilities } from '../view/host-capabilities'
|
|
13
|
+
|
|
14
|
+
describe('View external navigation host effect', () => {
|
|
15
|
+
test('the real Shell pipeline opens only an exact granted origin and replies to the physical child', async () => {
|
|
16
|
+
const sent: Array<{ windowId: string; message: unknown }> = []
|
|
17
|
+
const children = {
|
|
18
|
+
send: (windowId: string, value: unknown) => sent.push({ windowId, message: value }),
|
|
19
|
+
has: () => true,
|
|
20
|
+
on: () => () => undefined,
|
|
21
|
+
onClose: () => () => undefined,
|
|
22
|
+
} as ChildrenChannel
|
|
23
|
+
const capabilities = viewHostCapabilities(['https://connect.nango.dev'])
|
|
24
|
+
const pipeline = createIntentPipeline()
|
|
25
|
+
pipeline.use(
|
|
26
|
+
capabilitiesMiddleware({
|
|
27
|
+
lookup: (sender) => (sender === 'physical-child' ? capabilities : undefined),
|
|
28
|
+
}),
|
|
29
|
+
)
|
|
30
|
+
const router = createIntentRouter({
|
|
31
|
+
selfWindowId: 'root',
|
|
32
|
+
pipeline,
|
|
33
|
+
parent: null,
|
|
34
|
+
children,
|
|
35
|
+
})
|
|
36
|
+
const open = mock((_request: ExternalOpenRequest) => true)
|
|
37
|
+
installExternalOpenIntentHandler(
|
|
38
|
+
{
|
|
39
|
+
children,
|
|
40
|
+
onIntent: router.onLocal.bind(router),
|
|
41
|
+
} as unknown as Shell,
|
|
42
|
+
{ open },
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
await router.handleInbound(
|
|
46
|
+
'child',
|
|
47
|
+
message('https://connect.nango.dev/session', 'forged-sibling'),
|
|
48
|
+
'physical-child',
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
expect(open).toHaveBeenCalledTimes(1)
|
|
52
|
+
expect(sent).toEqual([
|
|
53
|
+
{
|
|
54
|
+
windowId: 'physical-child',
|
|
55
|
+
message: expect.objectContaining({
|
|
56
|
+
envelope: expect.objectContaining({
|
|
57
|
+
payload: { correlationId: 'request-1', result: { outcome: 'opened' } },
|
|
58
|
+
}),
|
|
59
|
+
}),
|
|
60
|
+
},
|
|
61
|
+
])
|
|
62
|
+
|
|
63
|
+
for (const denied of [
|
|
64
|
+
'http://connect.nango.dev/session',
|
|
65
|
+
'https://sub.connect.nango.dev/session',
|
|
66
|
+
'https://connect.nango.dev:444/session',
|
|
67
|
+
'https://evil.example/session',
|
|
68
|
+
]) {
|
|
69
|
+
await router.handleInbound('child', message(denied), 'physical-child')
|
|
70
|
+
}
|
|
71
|
+
await router.handleInbound('child', message('https://connect.nango.dev/session'), 'ungranted')
|
|
72
|
+
expect(open).toHaveBeenCalledTimes(1)
|
|
73
|
+
expect(sent).toHaveLength(1)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('reports a popup block through the admitted pipeline', async () => {
|
|
77
|
+
const sent = mock((_windowId: string, _message: unknown) => undefined)
|
|
78
|
+
const children = {
|
|
79
|
+
send: sent,
|
|
80
|
+
has: () => true,
|
|
81
|
+
on: () => () => undefined,
|
|
82
|
+
onClose: () => () => undefined,
|
|
83
|
+
} as ChildrenChannel
|
|
84
|
+
const pipeline = createIntentPipeline()
|
|
85
|
+
pipeline.use(
|
|
86
|
+
capabilitiesMiddleware({
|
|
87
|
+
lookup: () => viewHostCapabilities(['https://connect.nango.dev']),
|
|
88
|
+
}),
|
|
89
|
+
)
|
|
90
|
+
const router = createIntentRouter({ selfWindowId: 'root', pipeline, parent: null, children })
|
|
91
|
+
installExternalOpenIntentHandler(
|
|
92
|
+
{ children, onIntent: router.onLocal.bind(router) } as unknown as Shell,
|
|
93
|
+
{ open: () => false },
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
await router.handleInbound('child', message('https://connect.nango.dev/session'), 'child-1')
|
|
97
|
+
|
|
98
|
+
expect(sent).toHaveBeenCalledWith(
|
|
99
|
+
'child-1',
|
|
100
|
+
expect.objectContaining({
|
|
101
|
+
envelope: expect.objectContaining({
|
|
102
|
+
payload: { correlationId: 'request-1', result: { outcome: 'blocked' } },
|
|
103
|
+
}),
|
|
104
|
+
}),
|
|
105
|
+
)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('isolates a fresh inert context before navigating and closes it when isolation fails', () => {
|
|
109
|
+
const replace = mock((_url: string) => undefined)
|
|
110
|
+
const opened = { opener: {}, location: { replace }, close: mock(() => undefined) }
|
|
111
|
+
const open = mock(() => opened)
|
|
112
|
+
|
|
113
|
+
expect(
|
|
114
|
+
openExternalBrowserWindow({ open } as never, {
|
|
115
|
+
url: 'https://connect.nango.dev/session',
|
|
116
|
+
mode: 'popup',
|
|
117
|
+
}),
|
|
118
|
+
).toBe(true)
|
|
119
|
+
expect(open).toHaveBeenCalledWith('', '_blank', 'popup,width=720,height=760')
|
|
120
|
+
expect(opened.opener).toBeNull()
|
|
121
|
+
expect(replace).toHaveBeenCalledWith('https://connect.nango.dev/session')
|
|
122
|
+
|
|
123
|
+
const close = mock(() => undefined)
|
|
124
|
+
const unsafe = Object.defineProperty({ close, location: { replace: mock() } }, 'opener', {
|
|
125
|
+
set: () => {
|
|
126
|
+
throw new Error('opener isolation refused')
|
|
127
|
+
},
|
|
128
|
+
})
|
|
129
|
+
expect(
|
|
130
|
+
openExternalBrowserWindow({ open: () => unsafe } as never, {
|
|
131
|
+
url: 'https://connect.nango.dev/session',
|
|
132
|
+
mode: 'tab',
|
|
133
|
+
}),
|
|
134
|
+
).toBe(false)
|
|
135
|
+
expect(close).toHaveBeenCalledTimes(1)
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
function message(url: string, sender = 'child-1'): ExternalOpenIntentMessage {
|
|
140
|
+
return {
|
|
141
|
+
type: 'intent',
|
|
142
|
+
version: 1,
|
|
143
|
+
envelope: {
|
|
144
|
+
name: 'browser.openExternal',
|
|
145
|
+
payload: { url, mode: 'popup' },
|
|
146
|
+
sender: { windowId: sender },
|
|
147
|
+
correlationId: 'request-1',
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { admitExternalOpenOrigins } from '../view/external-open-origins'
|
|
4
|
+
|
|
5
|
+
describe('View external navigation grants', () => {
|
|
6
|
+
test('defaults to no authority and canonicalizes exact HTTPS origins', () => {
|
|
7
|
+
expect(admitExternalOpenOrigins(undefined)).toEqual([])
|
|
8
|
+
expect(
|
|
9
|
+
admitExternalOpenOrigins([
|
|
10
|
+
'https://connect.nango.dev',
|
|
11
|
+
'https://connect.nango.dev/',
|
|
12
|
+
'https://connect.composio.dev:443',
|
|
13
|
+
]),
|
|
14
|
+
).toEqual(['https://connect.nango.dev', 'https://connect.composio.dev'])
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test.each([
|
|
18
|
+
'http://connect.nango.dev',
|
|
19
|
+
'https://user@connect.nango.dev',
|
|
20
|
+
'https://connect.nango.dev/path',
|
|
21
|
+
'https://connect.nango.dev?session=secret',
|
|
22
|
+
'https://*.example.com',
|
|
23
|
+
'not-a-url',
|
|
24
|
+
])('rejects a non-origin grant: %s', (candidate) => {
|
|
25
|
+
expect(() => admitExternalOpenOrigins([candidate])).toThrow('exact HTTPS origin')
|
|
26
|
+
})
|
|
27
|
+
})
|
|
@@ -60,12 +60,20 @@ describe('view session server credentials', () => {
|
|
|
60
60
|
issuer: 'https://kernel.test',
|
|
61
61
|
direct: true,
|
|
62
62
|
},
|
|
63
|
+
externalOrigins: ['https://connect.nango.dev'],
|
|
63
64
|
idleMs: 60_000,
|
|
64
65
|
} satisfies ViewServeConfig
|
|
65
66
|
const server = startViewServer(config)
|
|
66
67
|
await once(server, 'listening')
|
|
67
68
|
|
|
68
69
|
try {
|
|
70
|
+
const configResponse = await fetch(`http://127.0.0.1:${port}/s/${nonce}/config.json`)
|
|
71
|
+
expect(configResponse.status).toBe(200)
|
|
72
|
+
expect(await configResponse.json()).toMatchObject({
|
|
73
|
+
sessionId: 'v-plain',
|
|
74
|
+
externalOrigins: ['https://connect.nango.dev'],
|
|
75
|
+
})
|
|
76
|
+
|
|
69
77
|
const response = await fetch(`http://127.0.0.1:${port}/s/${nonce}/token`, {
|
|
70
78
|
method: 'POST',
|
|
71
79
|
})
|
package/src/lib/update.ts
CHANGED
|
@@ -231,9 +231,40 @@ async function realpathIfExists(path: string): Promise<string | undefined> {
|
|
|
231
231
|
export async function writeInstallMetadata(
|
|
232
232
|
meta: InstallMetadata,
|
|
233
233
|
path = INSTALL_PATH,
|
|
234
|
+
filesystem: Pick<CohortFilesystem, 'mkdir' | 'rename' | 'rm'> &
|
|
235
|
+
Pick<typeof import('node:fs/promises'), 'writeFile'> = { mkdir, rename, rm, writeFile },
|
|
234
236
|
): Promise<void> {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
+
const staged = `${path}.next`
|
|
238
|
+
const previous = `${path}.previous`
|
|
239
|
+
await filesystem.mkdir(dirname(path), { recursive: true })
|
|
240
|
+
await filesystem.rm(staged, { force: true })
|
|
241
|
+
await filesystem.rm(previous, { force: true })
|
|
242
|
+
await filesystem.writeFile(staged, JSON.stringify(meta, null, 2) + '\n')
|
|
243
|
+
|
|
244
|
+
let backedUp = false
|
|
245
|
+
try {
|
|
246
|
+
try {
|
|
247
|
+
await filesystem.rename(path, previous)
|
|
248
|
+
backedUp = true
|
|
249
|
+
} catch (error) {
|
|
250
|
+
if (!isMissingFile(error)) throw error
|
|
251
|
+
}
|
|
252
|
+
await filesystem.rename(staged, path)
|
|
253
|
+
await filesystem.rm(previous, { force: true })
|
|
254
|
+
} catch (error) {
|
|
255
|
+
const rollback: unknown[] = []
|
|
256
|
+
if (backedUp) {
|
|
257
|
+
await filesystem.rename(previous, path).catch((failure) => rollback.push(failure))
|
|
258
|
+
}
|
|
259
|
+
await filesystem.rm(staged, { force: true }).catch((failure) => rollback.push(failure))
|
|
260
|
+
if (rollback.length > 0) {
|
|
261
|
+
throw new AggregateError(
|
|
262
|
+
[error, ...rollback],
|
|
263
|
+
'Install metadata update and rollback both failed.',
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
throw error
|
|
267
|
+
}
|
|
237
268
|
}
|
|
238
269
|
|
|
239
270
|
export function releaseBase(
|
|
@@ -258,7 +289,127 @@ export function shouldUpdate(currentVersion: string, manifestVersion: string): b
|
|
|
258
289
|
return currentVersion !== manifestVersion
|
|
259
290
|
}
|
|
260
291
|
|
|
261
|
-
|
|
292
|
+
interface CohortFilesystem {
|
|
293
|
+
readonly chmod: typeof chmod
|
|
294
|
+
readonly copyFile: typeof copyFile
|
|
295
|
+
readonly mkdir: typeof mkdir
|
|
296
|
+
readonly rename: typeof rename
|
|
297
|
+
readonly rm: typeof rm
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const defaultCohortFilesystem: CohortFilesystem = { chmod, copyFile, mkdir, rename, rm }
|
|
301
|
+
|
|
302
|
+
export interface StandaloneCohortReplacement {
|
|
303
|
+
readonly finalize: () => Promise<void>
|
|
304
|
+
readonly rollback: () => Promise<void>
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Replace the standalone executable and its Viewer as one rollback-safe cohort. */
|
|
308
|
+
export async function replaceStandaloneCohort(
|
|
309
|
+
installedBinary: string,
|
|
310
|
+
nextBinary: string,
|
|
311
|
+
nextViewerDist: string,
|
|
312
|
+
filesystem: Partial<CohortFilesystem> = {},
|
|
313
|
+
): Promise<StandaloneCohortReplacement> {
|
|
314
|
+
const fs = { ...defaultCohortFilesystem, ...filesystem }
|
|
315
|
+
const binDirectory = dirname(installedBinary)
|
|
316
|
+
const previousBinary = `${installedBinary}.previous`
|
|
317
|
+
const stagedBinary = `${installedBinary}.next`
|
|
318
|
+
const viewer = join(binDirectory, 'viewer')
|
|
319
|
+
const previousViewer = join(binDirectory, 'viewer.previous')
|
|
320
|
+
const stagedViewer = join(binDirectory, 'viewer.next')
|
|
321
|
+
const stagedViewerDist = join(stagedViewer, 'dist')
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
await fs.rm(stagedViewer, { recursive: true, force: true })
|
|
325
|
+
await fs.mkdir(stagedViewerDist, { recursive: true })
|
|
326
|
+
await fs.copyFile(join(nextViewerDist, 'main.js'), join(stagedViewerDist, 'main.js'))
|
|
327
|
+
await fs.copyFile(join(nextViewerDist, 'index.html'), join(stagedViewerDist, 'index.html'))
|
|
328
|
+
await fs.copyFile(installedBinary, previousBinary)
|
|
329
|
+
await fs.copyFile(nextBinary, stagedBinary)
|
|
330
|
+
await fs.chmod(stagedBinary, 0o755)
|
|
331
|
+
} catch (error) {
|
|
332
|
+
await fs.rm(stagedViewer, { recursive: true, force: true }).catch(() => undefined)
|
|
333
|
+
await fs.rm(stagedBinary, { force: true }).catch(() => undefined)
|
|
334
|
+
throw error
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
await fs.rm(previousViewer, { recursive: true, force: true })
|
|
338
|
+
let viewerBackedUp = false
|
|
339
|
+
let viewerCommitted = false
|
|
340
|
+
let binaryCommitted = false
|
|
341
|
+
try {
|
|
342
|
+
try {
|
|
343
|
+
await fs.rename(viewer, previousViewer)
|
|
344
|
+
viewerBackedUp = true
|
|
345
|
+
} catch (error) {
|
|
346
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
347
|
+
}
|
|
348
|
+
await fs.rename(stagedViewer, viewer)
|
|
349
|
+
viewerCommitted = true
|
|
350
|
+
await fs.rename(stagedBinary, installedBinary)
|
|
351
|
+
binaryCommitted = true
|
|
352
|
+
} catch (error) {
|
|
353
|
+
const rollback: unknown[] = []
|
|
354
|
+
if (binaryCommitted) {
|
|
355
|
+
await fs.copyFile(previousBinary, installedBinary).catch((failure) => rollback.push(failure))
|
|
356
|
+
await fs.chmod(installedBinary, 0o755).catch((failure) => rollback.push(failure))
|
|
357
|
+
}
|
|
358
|
+
if (viewerCommitted) {
|
|
359
|
+
await fs
|
|
360
|
+
.rm(viewer, { recursive: true, force: true })
|
|
361
|
+
.catch((failure) => rollback.push(failure))
|
|
362
|
+
}
|
|
363
|
+
if (viewerBackedUp) {
|
|
364
|
+
await fs.rename(previousViewer, viewer).catch((failure) => rollback.push(failure))
|
|
365
|
+
}
|
|
366
|
+
if (rollback.length > 0) {
|
|
367
|
+
throw new AggregateError([error, ...rollback], 'Standalone update and rollback both failed.')
|
|
368
|
+
}
|
|
369
|
+
throw error
|
|
370
|
+
} finally {
|
|
371
|
+
await fs.rm(stagedViewer, { recursive: true, force: true }).catch(() => undefined)
|
|
372
|
+
await fs.rm(stagedBinary, { force: true }).catch(() => undefined)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
let settled = false
|
|
376
|
+
return Object.freeze({
|
|
377
|
+
finalize: async () => {
|
|
378
|
+
if (settled) return
|
|
379
|
+
settled = true
|
|
380
|
+
await fs.rm(previousViewer, { recursive: true, force: true }).catch(() => undefined)
|
|
381
|
+
},
|
|
382
|
+
rollback: async () => {
|
|
383
|
+
if (settled) return
|
|
384
|
+
settled = true
|
|
385
|
+
const rollback: unknown[] = []
|
|
386
|
+
await fs.copyFile(previousBinary, installedBinary).catch((failure) => rollback.push(failure))
|
|
387
|
+
await fs.chmod(installedBinary, 0o755).catch((failure) => rollback.push(failure))
|
|
388
|
+
await fs
|
|
389
|
+
.rm(viewer, { recursive: true, force: true })
|
|
390
|
+
.catch((failure) => rollback.push(failure))
|
|
391
|
+
if (viewerBackedUp) {
|
|
392
|
+
await fs.rename(previousViewer, viewer).catch((failure) => rollback.push(failure))
|
|
393
|
+
}
|
|
394
|
+
if (rollback.length > 0) {
|
|
395
|
+
throw new AggregateError(rollback, 'Standalone update rollback failed.')
|
|
396
|
+
}
|
|
397
|
+
},
|
|
398
|
+
})
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
interface UpdateDependencies {
|
|
402
|
+
readonly replaceStandaloneCohort: typeof replaceStandaloneCohort
|
|
403
|
+
readonly writeInstallMetadata: typeof writeInstallMetadata
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const defaultUpdateDependencies = Object.freeze({ replaceStandaloneCohort, writeInstallMetadata })
|
|
407
|
+
|
|
408
|
+
export async function updateAstrale(
|
|
409
|
+
req: UpdateRequest,
|
|
410
|
+
dependencies: Partial<UpdateDependencies> = {},
|
|
411
|
+
): Promise<UpdateResult> {
|
|
412
|
+
const update = { ...defaultUpdateDependencies, ...dependencies }
|
|
262
413
|
const execution = req.execution ?? detectUpdateExecution()
|
|
263
414
|
if (execution.kind === 'package-managed') {
|
|
264
415
|
return {
|
|
@@ -320,25 +471,33 @@ export async function updateAstrale(req: UpdateRequest): Promise<UpdateResult> {
|
|
|
320
471
|
|
|
321
472
|
await extractTarGz(archive, tmp)
|
|
322
473
|
const nextBin = join(tmp, 'astrale')
|
|
474
|
+
const nextViewer = join(tmp, 'viewer', 'dist')
|
|
323
475
|
await chmod(nextBin, 0o755)
|
|
324
476
|
await smokeVersion(nextBin, manifest.binaryVersion ?? manifest.version)
|
|
325
477
|
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
478
|
+
const replacement = await update.replaceStandaloneCohort(meta.bin, nextBin, nextViewer)
|
|
479
|
+
try {
|
|
480
|
+
await update.writeInstallMetadata(
|
|
481
|
+
{
|
|
482
|
+
...meta,
|
|
483
|
+
channel: manifest.channel,
|
|
484
|
+
version: manifest.version,
|
|
485
|
+
installedAt: new Date().toISOString(),
|
|
486
|
+
},
|
|
487
|
+
req.installPath,
|
|
488
|
+
)
|
|
489
|
+
} catch (error) {
|
|
490
|
+
try {
|
|
491
|
+
await replacement.rollback()
|
|
492
|
+
} catch (rollbackError) {
|
|
493
|
+
throw new AggregateError(
|
|
494
|
+
[error, rollbackError],
|
|
495
|
+
'Standalone update metadata commit and cohort rollback both failed.',
|
|
496
|
+
)
|
|
497
|
+
}
|
|
498
|
+
throw error
|
|
499
|
+
}
|
|
500
|
+
await replacement.finalize()
|
|
342
501
|
|
|
343
502
|
return {
|
|
344
503
|
status: 'updated',
|
package/src/lib/view/assets.ts
CHANGED
|
@@ -8,7 +8,11 @@ import { fileURLToPath } from 'node:url'
|
|
|
8
8
|
* (`<pkg>/viewer/dist`). The module URL is authoritative because npm global
|
|
9
9
|
* installs expose the CLI through a bin symlink outside the package root.
|
|
10
10
|
*/
|
|
11
|
-
export function viewerDistDir(
|
|
11
|
+
export function viewerDistDir(
|
|
12
|
+
moduleUrl = import.meta.url,
|
|
13
|
+
entry = process.argv[1] ?? '.',
|
|
14
|
+
executable = process.execPath,
|
|
15
|
+
): string {
|
|
12
16
|
const override = process.env.ASTRALE_VIEWER_DIR
|
|
13
17
|
if (override) return override
|
|
14
18
|
|
|
@@ -16,7 +20,12 @@ export function viewerDistDir(moduleUrl = import.meta.url, entry = process.argv[
|
|
|
16
20
|
const published = join(moduleDirectory, '..', 'viewer', 'dist')
|
|
17
21
|
const source = join(moduleDirectory, '..', '..', '..', 'viewer', 'dist')
|
|
18
22
|
const legacy = join(dirname(entry), '..', 'viewer', 'dist')
|
|
19
|
-
const
|
|
23
|
+
const standalone = entry.startsWith('/$bunfs/')
|
|
24
|
+
? join(dirname(executable), 'viewer', 'dist')
|
|
25
|
+
: undefined
|
|
26
|
+
const complete = [standalone, published, source, legacy].find(
|
|
27
|
+
(candidate): candidate is string => candidate !== undefined && hasViewerBundle(candidate),
|
|
28
|
+
)
|
|
20
29
|
if (complete) return complete
|
|
21
30
|
|
|
22
31
|
// A source checkout may intentionally omit generated dist assets. Keep the
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ExternalOpenRequest, IntentMessage, Shell } from '@astrale-os/shell'
|
|
2
|
+
|
|
3
|
+
import { replyToIntent } from '@astrale-os/shell'
|
|
4
|
+
|
|
5
|
+
export interface ExternalOpenIntentHost {
|
|
6
|
+
open(request: ExternalOpenRequest): boolean
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Register the root host's browser-owned external navigation effect. */
|
|
10
|
+
export function installExternalOpenIntentHandler(
|
|
11
|
+
shell: Shell,
|
|
12
|
+
host: ExternalOpenIntentHost,
|
|
13
|
+
): () => void {
|
|
14
|
+
return shell.onIntent('browser.openExternal', (message) => {
|
|
15
|
+
const opened = host.open(message.envelope.payload)
|
|
16
|
+
replyToIntent(shell.children, message.envelope.sender.windowId, message, {
|
|
17
|
+
outcome: opened ? 'opened' : 'blocked',
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Open an external document without retaining a cross-origin opener capability. */
|
|
23
|
+
export function openExternalBrowserWindow(
|
|
24
|
+
browser: Pick<Window, 'open'>,
|
|
25
|
+
request: ExternalOpenRequest,
|
|
26
|
+
): boolean {
|
|
27
|
+
const popup = request.mode === 'popup'
|
|
28
|
+
const opened = browser.open('', '_blank', popup ? 'popup,width=720,height=760' : undefined)
|
|
29
|
+
if (opened === null) return false
|
|
30
|
+
try {
|
|
31
|
+
opened.opener = null
|
|
32
|
+
if (opened.opener !== null) throw new Error('Browser retained the opener capability.')
|
|
33
|
+
opened.location.replace(request.url)
|
|
34
|
+
} catch {
|
|
35
|
+
opened.close()
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
return true
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type ExternalOpenIntentMessage = IntentMessage<'browser.openExternal'>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { AstraleError } from '../../errors.js'
|
|
2
|
+
|
|
3
|
+
/** Admit exact HTTPS origins explicitly granted by the CLI operator. */
|
|
4
|
+
export function admitExternalOpenOrigins(input: readonly string[] | undefined): readonly string[] {
|
|
5
|
+
if (input === undefined) return Object.freeze([])
|
|
6
|
+
const admitted = new Set<string>()
|
|
7
|
+
for (const candidate of input) {
|
|
8
|
+
let url: URL
|
|
9
|
+
try {
|
|
10
|
+
url = new URL(candidate)
|
|
11
|
+
} catch (cause) {
|
|
12
|
+
throw invalidExternalOrigin(candidate, cause)
|
|
13
|
+
}
|
|
14
|
+
if (
|
|
15
|
+
url.protocol !== 'https:' ||
|
|
16
|
+
url.hostname.includes('*') ||
|
|
17
|
+
url.username !== '' ||
|
|
18
|
+
url.password !== '' ||
|
|
19
|
+
url.pathname !== '/' ||
|
|
20
|
+
url.search !== '' ||
|
|
21
|
+
url.hash !== ''
|
|
22
|
+
) {
|
|
23
|
+
throw invalidExternalOrigin(candidate)
|
|
24
|
+
}
|
|
25
|
+
admitted.add(url.origin)
|
|
26
|
+
}
|
|
27
|
+
return Object.freeze([...admitted])
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function invalidExternalOrigin(candidate: string, cause?: unknown): AstraleError {
|
|
31
|
+
return new AstraleError(
|
|
32
|
+
'INVALID_EXTERNAL_ORIGIN',
|
|
33
|
+
`External navigation grant "${candidate}" is not an exact HTTPS origin.`,
|
|
34
|
+
'Pass an origin such as https://connect.example.com with no credentials, path, query, or fragment.',
|
|
35
|
+
cause === undefined ? undefined : { cause },
|
|
36
|
+
)
|
|
37
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { HostCapabilities } from '@astrale-os/shell'
|
|
2
|
+
|
|
3
|
+
import { admitHostCapabilities } from '@astrale-os/shell'
|
|
4
|
+
|
|
5
|
+
/** Build the exact host grant handed to a CLI-hosted View session. */
|
|
6
|
+
export function viewHostCapabilities(externalOrigins: readonly string[]): HostCapabilities {
|
|
7
|
+
return admitHostCapabilities({
|
|
8
|
+
version: 1,
|
|
9
|
+
navigation: {
|
|
10
|
+
openView: {},
|
|
11
|
+
...(externalOrigins.length === 0 ? {} : { external: { origins: externalOrigins } }),
|
|
12
|
+
},
|
|
13
|
+
actions: {},
|
|
14
|
+
browser: {},
|
|
15
|
+
access: {},
|
|
16
|
+
})
|
|
17
|
+
}
|
package/src/lib/view/server.ts
CHANGED
package/src/lib/view/session.ts
CHANGED
|
@@ -41,6 +41,8 @@ export type ViewServeConfig = {
|
|
|
41
41
|
* handles CORS and self-signed local CAs.
|
|
42
42
|
*/
|
|
43
43
|
proxy: { kernelUrl: string; issuer: string; caFile?: string; direct: boolean }
|
|
44
|
+
/** Exact HTTPS origins the operator consented to open from this View session. */
|
|
45
|
+
externalOrigins: readonly string[]
|
|
44
46
|
idleMs: number
|
|
45
47
|
}
|
|
46
48
|
|
|
@@ -195,7 +195,7 @@ describe('program composition', () => {
|
|
|
195
195
|
'whoami',
|
|
196
196
|
])
|
|
197
197
|
expect(createHash('sha256').update(JSON.stringify(surface)).digest('hex')).toBe(
|
|
198
|
-
'
|
|
198
|
+
'51546dd7ea5cbf25efbd41f0cd09ceb480abad5be489fc16743500a18e3a02c6',
|
|
199
199
|
)
|
|
200
200
|
})
|
|
201
201
|
|
package/studio/package.json
CHANGED
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
"typecheck": "tsgo --noEmit"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"@astrale-os/sdk": "0.5.0-beta.
|
|
25
|
-
"@astrale-os/shell": "0.4.2-beta.
|
|
24
|
+
"@astrale-os/sdk": "0.5.0-beta.50",
|
|
25
|
+
"@astrale-os/shell": "0.4.2-beta.5",
|
|
26
26
|
"@dagrejs/dagre": "^3.0.0",
|
|
27
27
|
"@radix-ui/react-collapsible": "^1.1.0",
|
|
28
28
|
"@radix-ui/react-dialog": "^1.1.6",
|