agent-canvas 0.2.0 → 0.6.0
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/README.md +42 -17
- package/bin/agent-canvas.js +7 -7
- package/bin/agent-canvas.ts +48 -37
- package/bin/lib/commands/daemon.ts +46 -43
- package/bin/lib/commands/exec.ts +100 -100
- package/bin/lib/commands/fetch.ts +15 -15
- package/bin/lib/commands/install.ts +36 -36
- package/bin/lib/commands/instructions.ts +51 -51
- package/bin/lib/commands/push.ts +115 -97
- package/bin/lib/commands/review.ts +190 -0
- package/bin/lib/commands/watch.ts +24 -24
- package/bin/lib/config.ts +12 -12
- package/bin/lib/daemon-lifecycle.ts +47 -51
- package/bin/lib/helpers.ts +82 -76
- package/bin/lib/instructions/app-review.md +88 -0
- package/bin/lib/instructions/component-checklist.md +7 -5
- package/bin/lib/instructions/component-custom.md +20 -14
- package/bin/lib/instructions/component-diff.md +4 -3
- package/bin/lib/instructions/component-item.md +18 -8
- package/bin/lib/instructions/component-markdown.md +4 -2
- package/bin/lib/instructions/component-mermaid.md +12 -6
- package/bin/lib/instructions/component-multichoice.md +6 -2
- package/bin/lib/instructions/component-note.md +1 -1
- package/bin/lib/instructions/component-priority.md +1 -1
- package/bin/lib/instructions/component-rangeinput.md +9 -2
- package/bin/lib/instructions/component-secretinput.md +12 -11
- package/bin/lib/instructions/component-svg.md +62 -16
- package/bin/lib/instructions/component-table.md +6 -6
- package/bin/lib/instructions/component-usefeedback.md +8 -8
- package/bin/lib/instructions/component-userinput.md +6 -2
- package/bin/lib/instructions/flow-feature.md +22 -5
- package/bin/lib/instructions/flow-plan.md +5 -5
- package/bin/lib/instructions/flow-runbook.md +28 -25
- package/bin/lib/instructions/overview.md +27 -18
- package/bin/lib/instructions/theming.md +5 -0
- package/daemon/build.ts +171 -152
- package/daemon/client/App.tsx +1205 -1077
- package/daemon/client/CompareView.tsx +478 -458
- package/daemon/client/FileBrowser.tsx +241 -203
- package/daemon/client/FileViewer.tsx +220 -211
- package/daemon/client/OverviewView.tsx +283 -267
- package/daemon/client/ReviewerIdentityDialog.tsx +72 -71
- package/daemon/client/RevisionSelect.tsx +111 -95
- package/daemon/client/SessionSwitcher.tsx +81 -81
- package/daemon/client/ShareDialog.tsx +211 -212
- package/daemon/client/apiSchemas.ts +78 -0
- package/daemon/client/canvas-runtime.ts +1 -1
- package/daemon/client/clientApi.ts +194 -206
- package/daemon/client/components/SecretInput.tsx +138 -138
- package/daemon/client/components/index.ts +2 -2
- package/daemon/client/domDiff.ts +341 -333
- package/daemon/client/errorMessage.ts +3 -0
- package/daemon/client/exportMarkdown.ts +222 -201
- package/daemon/client/lcs.ts +19 -14
- package/daemon/client/shareCrypto.ts +57 -57
- package/daemon/client/unifiedDiff.ts +660 -649
- package/daemon/dist/annotations-runtime.js +1 -1
- package/daemon/dist/client.css +2 -2
- package/daemon/dist/client.js +77 -80
- package/daemon/dist/components.js +59 -59
- package/daemon/dist/preact-compat.js +1 -1
- package/daemon/dist/review.js +361 -0
- package/daemon/dist/runtime.js +1 -1
- package/daemon/langMap.ts +24 -6
- package/daemon/review-widget/anchor.ts +187 -0
- package/daemon/review-widget/client.ts +95 -0
- package/daemon/review-widget/index.tsx +36 -0
- package/daemon/review-widget/markup.ts +71 -0
- package/daemon/review-widget/screenshot.ts +91 -0
- package/daemon/review-widget/source.ts +103 -0
- package/daemon/review-widget/styles.css +359 -0
- package/daemon/review-widget/text-modules.d.ts +4 -0
- package/daemon/review-widget/tsconfig.json +16 -0
- package/daemon/review-widget/widget.tsx +436 -0
- package/daemon/src/components.ts +3 -3
- package/daemon/src/handlers/api.ts +330 -328
- package/daemon/src/handlers/files.ts +112 -105
- package/daemon/src/handlers/static.ts +47 -46
- package/daemon/src/handlers/uploads.ts +61 -56
- package/daemon/src/paths.ts +13 -10
- package/daemon/src/remote-feedback.ts +192 -192
- package/daemon/src/review/markdown.ts +52 -0
- package/daemon/src/review/model.ts +132 -0
- package/daemon/src/review/proxy.ts +177 -0
- package/daemon/src/review/server.ts +199 -0
- package/daemon/src/review/store.ts +151 -0
- package/daemon/src/server.ts +127 -86
- package/daemon/src/share.ts +163 -169
- package/daemon/src/stubFetch.ts +7 -0
- package/daemon/tsconfig.json +19 -19
- package/package.json +47 -47
- package/skills/canvas/SKILL.md +9 -1
- package/bin/lib/commands/exec.test.ts +0 -27
- package/bin/lib/helpers.test.ts +0 -79
- package/daemon/src/remote-feedback.test.ts +0 -165
- package/daemon/src/secrets-api.test.ts +0 -123
- package/daemon/src/share-e2e.test.ts +0 -277
- package/daemon/src/share.test.ts +0 -289
package/daemon/src/server.ts
CHANGED
|
@@ -1,95 +1,136 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
} from
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const PORT = parseInt(process.env.CANVAS_PORT || "19400", 10);
|
|
19
|
-
const VERSION = JSON.parse(readFileSync(join(dirname(import.meta.dir), "..", "package.json"), "utf-8")).version as string;
|
|
1
|
+
import { createWebSocketManager, SessionManager, type WSData } from '@fabrika/canvas-kernel/server'
|
|
2
|
+
import { corsHeaders, dispatch, jsonResponse } from '@fabrika/daemon-kit'
|
|
3
|
+
import { randomBytes } from 'crypto'
|
|
4
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
5
|
+
import { dirname, join } from 'path'
|
|
6
|
+
import { createApiHandlers } from './handlers/api'
|
|
7
|
+
import { createFileHandlers } from './handlers/files'
|
|
8
|
+
import { createStaticHandlers } from './handlers/static'
|
|
9
|
+
import { createUploadHandlers } from './handlers/uploads'
|
|
10
|
+
import { CLI_AUTH_FILE, PID_FILE, REVIEWS_DIR, SESSIONS_DIR, TEMP_DIR } from './paths'
|
|
11
|
+
import { startRemoteFeedbackPoller } from './remote-feedback'
|
|
12
|
+
import { ReviewProxies } from './review/proxy'
|
|
13
|
+
import { createReviewServer, type ReviewSocketData } from './review/server'
|
|
14
|
+
import { ReviewStore } from './review/store'
|
|
15
|
+
|
|
16
|
+
const PORT = parseInt(process.env.CANVAS_PORT || '19400', 10)
|
|
17
|
+
const VERSION = JSON.parse(readFileSync(join(dirname(import.meta.dir), '..', 'package.json'), 'utf-8')).version as string
|
|
20
18
|
|
|
21
19
|
// Write PID file so CLI can find and stop us
|
|
22
|
-
mkdirSync(TEMP_DIR, { recursive: true, mode: 0o700 })
|
|
23
|
-
chmodSync(TEMP_DIR, 0o700)
|
|
24
|
-
writeFileSync(PID_FILE, String(process.pid))
|
|
25
|
-
const cliAuthToken = randomBytes(32).toString(
|
|
26
|
-
writeFileSync(CLI_AUTH_FILE, cliAuthToken, { mode: 0o600 })
|
|
27
|
-
chmodSync(CLI_AUTH_FILE, 0o600)
|
|
20
|
+
mkdirSync(TEMP_DIR, { recursive: true, mode: 0o700 })
|
|
21
|
+
chmodSync(TEMP_DIR, 0o700)
|
|
22
|
+
writeFileSync(PID_FILE, String(process.pid))
|
|
23
|
+
const cliAuthToken = randomBytes(32).toString('base64url')
|
|
24
|
+
writeFileSync(CLI_AUTH_FILE, cliAuthToken, { mode: 0o600 })
|
|
25
|
+
chmodSync(CLI_AUTH_FILE, 0o600)
|
|
26
|
+
|
|
27
|
+
const sessionManager = new SessionManager(SESSIONS_DIR)
|
|
28
|
+
const wsManager = createWebSocketManager(sessionManager)
|
|
29
|
+
const reviewStore = new ReviewStore(REVIEWS_DIR)
|
|
30
|
+
const reviewProxies = new ReviewProxies(reviewStore, `http://localhost:${PORT}/assets/review.js`)
|
|
31
|
+
const review = createReviewServer(reviewStore, reviewProxies)
|
|
32
|
+
|
|
33
|
+
type SocketData = WSData | ReviewSocketData
|
|
34
|
+
type Socket = Bun.ServerWebSocket<SocketData>
|
|
35
|
+
|
|
36
|
+
function isReviewSocket(socket: Socket): socket is Bun.ServerWebSocket<ReviewSocketData> {
|
|
37
|
+
return socket.data.type === 'review'
|
|
38
|
+
}
|
|
28
39
|
|
|
29
|
-
|
|
30
|
-
|
|
40
|
+
function isCanvasSocket(socket: Socket): socket is Bun.ServerWebSocket<WSData> {
|
|
41
|
+
return socket.data.type !== 'review'
|
|
42
|
+
}
|
|
31
43
|
|
|
32
44
|
const routes = [
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
45
|
+
...createApiHandlers({
|
|
46
|
+
sessionManager,
|
|
47
|
+
broadcastPlanUpdate: wsManager.broadcastPlanUpdate,
|
|
48
|
+
broadcastRevisionUpdate: wsManager.broadcastRevisionUpdate,
|
|
49
|
+
port: PORT,
|
|
50
|
+
version: VERSION,
|
|
51
|
+
cliAuthToken,
|
|
52
|
+
}),
|
|
53
|
+
...createFileHandlers(sessionManager),
|
|
54
|
+
...createUploadHandlers(sessionManager),
|
|
55
|
+
...createStaticHandlers(),
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
const server = Bun.serve<SocketData>({
|
|
59
|
+
port: PORT,
|
|
60
|
+
hostname: 'localhost',
|
|
61
|
+
fetch(req, server) {
|
|
62
|
+
const url = new URL(req.url)
|
|
63
|
+
|
|
64
|
+
// Review mode answers its own preflights: the application under review runs on another port.
|
|
65
|
+
const reviewResponse = review.fetch(req, url, server)
|
|
66
|
+
if (reviewResponse === 'upgraded') return undefined
|
|
67
|
+
if (reviewResponse !== null) return reviewResponse
|
|
68
|
+
|
|
69
|
+
// CORS preflight
|
|
70
|
+
if (req.method === 'OPTIONS') {
|
|
71
|
+
return new Response(null, { status: 204, headers: corsHeaders(PORT) })
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// WebSocket upgrade: /ws/session/:id
|
|
75
|
+
const wsBrowserMatch = url.pathname.match(/^\/ws\/session\/([^/]+)$/)
|
|
76
|
+
if (wsBrowserMatch) {
|
|
77
|
+
const upgraded = server.upgrade(req, { data: { type: 'browser', sessionId: wsBrowserMatch[1] } })
|
|
78
|
+
if (!upgraded) return new Response('WebSocket upgrade failed', { status: 400 })
|
|
79
|
+
return undefined
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// WebSocket upgrade: /ws/wait/:id
|
|
83
|
+
const wsWaitMatch = url.pathname.match(/^\/ws\/wait\/([^/]+)$/)
|
|
84
|
+
if (wsWaitMatch) {
|
|
85
|
+
const upgraded = server.upgrade(req, { data: { type: 'wait', sessionId: wsWaitMatch[1] } })
|
|
86
|
+
if (!upgraded) return new Response('WebSocket upgrade failed', { status: 400 })
|
|
87
|
+
return undefined
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Route dispatch
|
|
91
|
+
const response = dispatch(routes, req, url)
|
|
92
|
+
if (response) return response
|
|
93
|
+
|
|
94
|
+
// Root redirect
|
|
95
|
+
if (url.pathname === '/' && req.method === 'GET') {
|
|
96
|
+
const sessions = sessionManager.list()
|
|
97
|
+
if (sessions.length > 0) {
|
|
98
|
+
const latest = sessions.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())[0]
|
|
99
|
+
return Response.redirect(`/s/${latest.id}`, 302)
|
|
100
|
+
}
|
|
101
|
+
return jsonResponse({ message: 'No active sessions', sessions: [] })
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return jsonResponse({ error: 'Not found' }, 404)
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
websocket: {
|
|
108
|
+
open(socket) {
|
|
109
|
+
if (isReviewSocket(socket)) review.channel.handlers.open(socket)
|
|
110
|
+
else if (isCanvasSocket(socket)) wsManager.handlers.open(socket)
|
|
111
|
+
},
|
|
112
|
+
message(socket, message) {
|
|
113
|
+
if (isReviewSocket(socket)) review.channel.handlers.message(socket, message)
|
|
114
|
+
else if (isCanvasSocket(socket)) wsManager.handlers.message(socket, message)
|
|
115
|
+
},
|
|
116
|
+
pong(socket) {
|
|
117
|
+
if (isReviewSocket(socket)) review.channel.handlers.pong(socket)
|
|
118
|
+
else if (isCanvasSocket(socket)) wsManager.handlers.pong(socket)
|
|
119
|
+
},
|
|
120
|
+
close(socket) {
|
|
121
|
+
if (isReviewSocket(socket)) review.channel.handlers.close(socket)
|
|
122
|
+
else if (isCanvasSocket(socket)) wsManager.handlers.close(socket)
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
export { server, sessionManager }
|
|
128
|
+
export const broadcastPlanUpdate = wsManager.broadcastPlanUpdate
|
|
90
129
|
|
|
91
130
|
// Start polling for remote feedback from shared canvases.
|
|
92
131
|
// No-ops if CANVAS_SHARE_ENDPOINT is not set.
|
|
93
|
-
startRemoteFeedbackPoller(sessionManager, wsManager.broadcastRemoteFeedback, VERSION)
|
|
132
|
+
startRemoteFeedbackPoller(sessionManager, wsManager.broadcastRemoteFeedback, VERSION)
|
|
133
|
+
|
|
134
|
+
reviewProxies.restore()
|
|
94
135
|
|
|
95
|
-
console.log(`Planner daemon listening on http://localhost:${PORT}`)
|
|
136
|
+
console.log(`Planner daemon listening on http://localhost:${PORT}`)
|
package/daemon/src/share.ts
CHANGED
|
@@ -1,11 +1,5 @@
|
|
|
1
|
-
import type { SessionManager, ShareEntry } from
|
|
2
|
-
import {
|
|
3
|
-
generateShareKey,
|
|
4
|
-
exportShareKey,
|
|
5
|
-
encryptString,
|
|
6
|
-
ENCRYPTION_META,
|
|
7
|
-
type EncryptionMeta,
|
|
8
|
-
} from "../client/shareCrypto";
|
|
1
|
+
import type { SessionManager, ShareEntry } from '@fabrika/canvas-kernel/server'
|
|
2
|
+
import { ENCRYPTION_META, type EncryptionMeta, encryptString, exportShareKey, generateShareKey } from '../client/shareCrypto'
|
|
9
3
|
|
|
10
4
|
/**
|
|
11
5
|
* Canvas sharing: packages a specific revision into a self-contained
|
|
@@ -33,58 +27,58 @@ import {
|
|
|
33
27
|
*/
|
|
34
28
|
|
|
35
29
|
export interface SharePayload {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
30
|
+
version: 1
|
|
31
|
+
/** Present iff payload fields below are ciphertext rather than plaintext. */
|
|
32
|
+
encryption?: EncryptionMeta
|
|
33
|
+
origin: {
|
|
34
|
+
sessionId: string
|
|
35
|
+
revision: number
|
|
36
|
+
label?: string
|
|
37
|
+
createdAt: string
|
|
38
|
+
}
|
|
39
|
+
canvasFiles: Array<{
|
|
40
|
+
filename: string
|
|
41
|
+
compiledJs: string
|
|
42
|
+
sourceJsx?: string
|
|
43
|
+
}>
|
|
44
|
+
runtime: {
|
|
45
|
+
componentsVersion: string
|
|
46
|
+
}
|
|
53
47
|
}
|
|
54
48
|
|
|
55
49
|
export interface ShareResponse {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
50
|
+
shareId: string
|
|
51
|
+
url: string
|
|
52
|
+
ownerToken?: string
|
|
53
|
+
expiresAt?: string
|
|
60
54
|
}
|
|
61
55
|
|
|
62
56
|
export interface ShareConfig {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
57
|
+
/** Base URL of the share worker, e.g. `https://canvas-share.example.workers.dev` */
|
|
58
|
+
endpoint: string
|
|
59
|
+
/** Version of the @agent-canvas runtime bundles — worker uses this to pick matching runtime */
|
|
60
|
+
componentsVersion: string
|
|
61
|
+
/** Optional bearer token. If the worker has SHARE_AUTH_TOKEN set, this
|
|
62
|
+
* must match. Loaded from CANVAS_SHARE_AUTH_TOKEN env var on the daemon. */
|
|
63
|
+
authToken?: string
|
|
64
|
+
/** End-to-end encryption setting. Defaults to true. */
|
|
65
|
+
encryption?: boolean
|
|
72
66
|
}
|
|
73
67
|
|
|
74
68
|
export function loadShareConfig(version: string): ShareConfig | null {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
69
|
+
const endpoint = process.env.CANVAS_SHARE_ENDPOINT ?? 'https://canvas.contember.com'
|
|
70
|
+
if (!endpoint) return null
|
|
71
|
+
return {
|
|
72
|
+
endpoint: endpoint.replace(/\/+$/, ''),
|
|
73
|
+
componentsVersion: version,
|
|
74
|
+
encryption: !isEncryptionDisabled(),
|
|
75
|
+
...(process.env.CANVAS_SHARE_AUTH_TOKEN ? { authToken: process.env.CANVAS_SHARE_AUTH_TOKEN } : {}),
|
|
76
|
+
}
|
|
83
77
|
}
|
|
84
78
|
|
|
85
79
|
function isEncryptionDisabled(): boolean {
|
|
86
|
-
|
|
87
|
-
|
|
80
|
+
const v = (process.env.CANVAS_SHARE_ENCRYPTION ?? '').trim().toLowerCase()
|
|
81
|
+
return v === '0' || v === 'off' || v === 'false' || v === 'no'
|
|
88
82
|
}
|
|
89
83
|
|
|
90
84
|
/**
|
|
@@ -93,42 +87,42 @@ function isEncryptionDisabled(): boolean {
|
|
|
93
87
|
* original JSX source for "view source" functionality on the shared canvas.
|
|
94
88
|
*/
|
|
95
89
|
export function buildSharePayload(
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
90
|
+
sessionManager: SessionManager,
|
|
91
|
+
sessionId: string,
|
|
92
|
+
revision: number,
|
|
93
|
+
componentsVersion: string,
|
|
100
94
|
): SharePayload {
|
|
101
|
-
|
|
102
|
-
|
|
95
|
+
const session = sessionManager.get(sessionId)
|
|
96
|
+
if (!session) throw new Error(`Session ${sessionId} not found`)
|
|
103
97
|
|
|
104
|
-
|
|
105
|
-
|
|
98
|
+
const revInfo = session.revisions.find((r) => r.revision === revision)
|
|
99
|
+
if (!revInfo) throw new Error(`Revision ${revision} not found in session ${sessionId}`)
|
|
106
100
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
101
|
+
const canvasFiles: SharePayload['canvasFiles'] = []
|
|
102
|
+
for (const cf of revInfo.canvasFiles) {
|
|
103
|
+
const compiledJs = sessionManager.getCompiled(sessionId, cf.filename, revision)
|
|
104
|
+
if (!compiledJs) {
|
|
105
|
+
throw new Error(`No compiled JS for ${cf.filename} at revision ${revision}`)
|
|
106
|
+
}
|
|
107
|
+
const sourceJsx = sessionManager.readRevisionJsx(sessionId, revision, cf.filename) ?? undefined
|
|
108
|
+
canvasFiles.push({
|
|
109
|
+
filename: cf.filename,
|
|
110
|
+
compiledJs,
|
|
111
|
+
...(sourceJsx ? { sourceJsx } : {}),
|
|
112
|
+
})
|
|
113
|
+
}
|
|
120
114
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
115
|
+
return {
|
|
116
|
+
version: 1,
|
|
117
|
+
origin: {
|
|
118
|
+
sessionId,
|
|
119
|
+
revision,
|
|
120
|
+
...(revInfo.label ? { label: revInfo.label } : {}),
|
|
121
|
+
createdAt: revInfo.createdAt,
|
|
122
|
+
},
|
|
123
|
+
canvasFiles,
|
|
124
|
+
runtime: { componentsVersion },
|
|
125
|
+
}
|
|
132
126
|
}
|
|
133
127
|
|
|
134
128
|
/**
|
|
@@ -141,30 +135,30 @@ export function buildSharePayload(
|
|
|
141
135
|
* Everything else (sessionId, label, compiledJs, sourceJsx) is encrypted.
|
|
142
136
|
*/
|
|
143
137
|
export async function encryptSharePayload(
|
|
144
|
-
|
|
145
|
-
|
|
138
|
+
payload: SharePayload,
|
|
139
|
+
key: CryptoKey,
|
|
146
140
|
): Promise<SharePayload> {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
141
|
+
const canvasFiles = await Promise.all(
|
|
142
|
+
payload.canvasFiles.map(async (cf) => ({
|
|
143
|
+
filename: cf.filename,
|
|
144
|
+
compiledJs: await encryptString(key, cf.compiledJs),
|
|
145
|
+
...(cf.sourceJsx ? { sourceJsx: await encryptString(key, cf.sourceJsx) } : {}),
|
|
146
|
+
})),
|
|
147
|
+
)
|
|
148
|
+
return {
|
|
149
|
+
version: payload.version,
|
|
150
|
+
encryption: ENCRYPTION_META,
|
|
151
|
+
origin: {
|
|
152
|
+
sessionId: await encryptString(key, payload.origin.sessionId),
|
|
153
|
+
revision: payload.origin.revision,
|
|
154
|
+
...(payload.origin.label
|
|
155
|
+
? { label: await encryptString(key, payload.origin.label) }
|
|
156
|
+
: {}),
|
|
157
|
+
createdAt: payload.origin.createdAt,
|
|
158
|
+
},
|
|
159
|
+
canvasFiles,
|
|
160
|
+
runtime: payload.runtime,
|
|
161
|
+
}
|
|
168
162
|
}
|
|
169
163
|
|
|
170
164
|
/**
|
|
@@ -174,31 +168,31 @@ export async function encryptSharePayload(
|
|
|
174
168
|
* fix their config.
|
|
175
169
|
*/
|
|
176
170
|
export async function pushShareToWorker(
|
|
177
|
-
|
|
178
|
-
|
|
171
|
+
payload: SharePayload,
|
|
172
|
+
config: ShareConfig,
|
|
179
173
|
): Promise<ShareResponse> {
|
|
180
|
-
|
|
181
|
-
|
|
174
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
|
175
|
+
if (config.authToken) headers['Authorization'] = `Bearer ${config.authToken}`
|
|
182
176
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
177
|
+
const res = await fetch(`${config.endpoint}/shares`, {
|
|
178
|
+
method: 'POST',
|
|
179
|
+
headers,
|
|
180
|
+
body: JSON.stringify(payload),
|
|
181
|
+
})
|
|
182
|
+
if (!res.ok) {
|
|
183
|
+
const text = await res.text().catch(() => '')
|
|
184
|
+
throw new Error(`Share endpoint returned ${res.status}: ${text || res.statusText}`)
|
|
185
|
+
}
|
|
186
|
+
const data = (await res.json()) as { shareId?: string; url?: string; ownerToken?: string; expiresAt?: string }
|
|
187
|
+
if (!data.shareId || !data.url) {
|
|
188
|
+
throw new Error(`Share endpoint returned invalid response: ${JSON.stringify(data)}`)
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
shareId: data.shareId,
|
|
192
|
+
url: data.url,
|
|
193
|
+
...(data.ownerToken ? { ownerToken: data.ownerToken } : {}),
|
|
194
|
+
...(data.expiresAt ? { expiresAt: data.expiresAt } : {}),
|
|
195
|
+
}
|
|
202
196
|
}
|
|
203
197
|
|
|
204
198
|
/**
|
|
@@ -208,36 +202,36 @@ export async function pushShareToWorker(
|
|
|
208
202
|
* paste it to reviewers.
|
|
209
203
|
*/
|
|
210
204
|
export async function shareRevision(
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
205
|
+
sessionManager: SessionManager,
|
|
206
|
+
sessionId: string,
|
|
207
|
+
revision: number,
|
|
208
|
+
config: ShareConfig,
|
|
215
209
|
): Promise<ShareEntry> {
|
|
216
|
-
|
|
210
|
+
const plaintext = buildSharePayload(sessionManager, sessionId, revision, config.componentsVersion)
|
|
217
211
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
212
|
+
const useEncryption = config.encryption !== false
|
|
213
|
+
let payload = plaintext
|
|
214
|
+
let encodedKey: string | undefined
|
|
215
|
+
if (useEncryption) {
|
|
216
|
+
const key = await generateShareKey()
|
|
217
|
+
encodedKey = await exportShareKey(key)
|
|
218
|
+
payload = await encryptSharePayload(plaintext, key)
|
|
219
|
+
}
|
|
226
220
|
|
|
227
|
-
|
|
228
|
-
|
|
221
|
+
const response = await pushShareToWorker(payload, config)
|
|
222
|
+
const url = encodedKey ? `${response.url}#k=${encodedKey}` : response.url
|
|
229
223
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
224
|
+
const entry: ShareEntry = {
|
|
225
|
+
shareId: response.shareId,
|
|
226
|
+
url,
|
|
227
|
+
revision,
|
|
228
|
+
createdAt: new Date().toISOString(),
|
|
229
|
+
...(response.ownerToken ? { ownerToken: response.ownerToken } : {}),
|
|
230
|
+
...(response.expiresAt ? { expiresAt: response.expiresAt } : {}),
|
|
231
|
+
...(encodedKey ? { encryptionKey: encodedKey } : {}),
|
|
232
|
+
}
|
|
233
|
+
sessionManager.addShare(sessionId, entry)
|
|
234
|
+
return entry
|
|
241
235
|
}
|
|
242
236
|
|
|
243
237
|
/**
|
|
@@ -245,23 +239,23 @@ export async function shareRevision(
|
|
|
245
239
|
* it from the local session metadata on success.
|
|
246
240
|
*/
|
|
247
241
|
export async function revokeShare(
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
242
|
+
sessionManager: SessionManager,
|
|
243
|
+
sessionId: string,
|
|
244
|
+
shareId: string,
|
|
245
|
+
config: ShareConfig,
|
|
252
246
|
): Promise<void> {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
247
|
+
const session = sessionManager.get(sessionId)
|
|
248
|
+
const entry = session?.shares?.find((s) => s.shareId === shareId)
|
|
249
|
+
if (!entry) throw new Error(`Share ${shareId} not found`)
|
|
250
|
+
if (!entry.ownerToken) throw new Error('No owner token recorded for this share')
|
|
257
251
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
252
|
+
const res = await fetch(`${config.endpoint}/shares/${shareId}/revoke`, {
|
|
253
|
+
method: 'POST',
|
|
254
|
+
headers: { 'Authorization': `Bearer ${entry.ownerToken}` },
|
|
255
|
+
})
|
|
256
|
+
if (!res.ok) {
|
|
257
|
+
const text = await res.text().catch(() => '')
|
|
258
|
+
throw new Error(`Revoke failed: ${res.status} ${text}`)
|
|
259
|
+
}
|
|
260
|
+
sessionManager.removeShare(sessionId, shareId)
|
|
267
261
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Replace `globalThis.fetch` for a test. The caller restores the original. */
|
|
2
|
+
export function stubFetch(handler: (url: string, init: RequestInit | undefined) => Response | Promise<Response>): void {
|
|
3
|
+
globalThis.fetch = Object.assign(
|
|
4
|
+
async (input: Parameters<typeof fetch>[0], init?: RequestInit) => handler(String(input), init),
|
|
5
|
+
{ preconnect: fetch.preconnect },
|
|
6
|
+
)
|
|
7
|
+
}
|