agent-canvas 0.1.34 → 0.1.36
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/daemon/client/App.tsx +2 -3
- package/daemon/client/PlanRenderer.tsx +2 -2
- package/daemon/client/ShareDialog.tsx +12 -0
- package/daemon/client/clientApi.ts +156 -3
- package/daemon/client/shareCrypto.ts +123 -0
- package/daemon/dist/client.js +68 -68
- package/daemon/dist/components.js +57 -57
- package/daemon/src/remote-feedback.ts +104 -4
- package/daemon/src/session.ts +5 -0
- package/daemon/src/share-e2e.test.ts +277 -0
- package/daemon/src/share.test.ts +76 -1
- package/daemon/src/share.ts +85 -5
- package/daemon/tsconfig.json +4 -1
- package/package.json +1 -1
- package/workers/canvas-share/public/assets/client.js +68 -68
- package/workers/canvas-share/public/assets/components.js +57 -57
- package/workers/canvas-share/src/index.test.ts +44 -0
- package/workers/canvas-share/src/index.ts +9 -1
- package/workers/canvas-share/src/types.ts +24 -2
- package/workers/canvas-share/src/validation.ts +61 -18
|
@@ -116,6 +116,50 @@ describe("validateFeedbackBody", () => {
|
|
|
116
116
|
annotations: [{ id: "a1", snippet: 123, note: "y", createdAt: "2026-04-09T00:00:00.000Z" }],
|
|
117
117
|
})).toBe("string");
|
|
118
118
|
});
|
|
119
|
+
|
|
120
|
+
test("accepts encrypted feedback with oversized ciphertext fields", () => {
|
|
121
|
+
// In encrypted mode plaintext caps don't apply: ciphertext is opaque
|
|
122
|
+
// base64 that can legitimately exceed AUTHOR_NAME_LENGTH /
|
|
123
|
+
// GENERAL_NOTE_LENGTH while staying well under the wire-size caps.
|
|
124
|
+
const result = validateFeedbackBody({
|
|
125
|
+
...valid,
|
|
126
|
+
author: { name: "x".repeat(LIMITS.AUTHOR_NAME_LENGTH + 200) },
|
|
127
|
+
generalNote: "x".repeat(LIMITS.GENERAL_NOTE_LENGTH + 5000),
|
|
128
|
+
encryption: { alg: "AES-GCM", v: 1 },
|
|
129
|
+
});
|
|
130
|
+
expect(typeof result).toBe("object");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("still rejects unknown encryption algorithm", () => {
|
|
134
|
+
expect(typeof validateFeedbackBody({
|
|
135
|
+
...valid,
|
|
136
|
+
encryption: { alg: "RSA", v: 1 },
|
|
137
|
+
})).toBe("string");
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("validateSharePayload encryption", () => {
|
|
142
|
+
test("accepts the encryption marker", () => {
|
|
143
|
+
const result = validateSharePayload({
|
|
144
|
+
version: 1,
|
|
145
|
+
encryption: { alg: "AES-GCM", v: 1 },
|
|
146
|
+
origin: { sessionId: "ciphertext", revision: 1, createdAt: "2026-04-09T00:00:00.000Z" },
|
|
147
|
+
canvasFiles: [{ filename: "plan.jsx", compiledJs: "ciphertext-blob" }],
|
|
148
|
+
runtime: { componentsVersion: "0.1.0" },
|
|
149
|
+
});
|
|
150
|
+
expect(typeof result).toBe("object");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("rejects unknown version of encryption", () => {
|
|
154
|
+
const result = validateSharePayload({
|
|
155
|
+
version: 1,
|
|
156
|
+
encryption: { alg: "AES-GCM", v: 99 },
|
|
157
|
+
origin: { sessionId: "x", revision: 1, createdAt: "2026-04-09T00:00:00.000Z" },
|
|
158
|
+
canvasFiles: [{ filename: "plan.jsx", compiledJs: "x" }],
|
|
159
|
+
runtime: { componentsVersion: "0.1.0" },
|
|
160
|
+
});
|
|
161
|
+
expect(typeof result).toBe("string");
|
|
162
|
+
});
|
|
119
163
|
});
|
|
120
164
|
|
|
121
165
|
describe("validateShareId", () => {
|
|
@@ -81,6 +81,7 @@ async function handleCreateShare(req: Request, env: Env, origin: string): Promis
|
|
|
81
81
|
componentsVersion: payload.runtime.componentsVersion,
|
|
82
82
|
origin: payload.origin,
|
|
83
83
|
canvasFilenames: payload.canvasFiles.map((cf) => cf.filename),
|
|
84
|
+
...(payload.encryption ? { encryption: payload.encryption } : {}),
|
|
84
85
|
};
|
|
85
86
|
|
|
86
87
|
const prefix = `shares/${shareId}`;
|
|
@@ -159,6 +160,9 @@ async function handleGetMeta(shareId: string, env: Env): Promise<Response> {
|
|
|
159
160
|
shares: [],
|
|
160
161
|
shareEnabled: false,
|
|
161
162
|
runtime: { componentsVersion: record.componentsVersion },
|
|
163
|
+
// Lets the browser know whether origin.sessionId / origin.label and
|
|
164
|
+
// canvas JS bodies need decrypting with the URL-fragment key.
|
|
165
|
+
...(record.encryption ? { encryption: record.encryption } : {}),
|
|
162
166
|
});
|
|
163
167
|
}
|
|
164
168
|
|
|
@@ -211,10 +215,14 @@ async function handleSubmitFeedback(shareId: string, req: Request, env: Env): Pr
|
|
|
211
215
|
submittedAt: new Date().toISOString(),
|
|
212
216
|
author: {
|
|
213
217
|
id: body.author.id || randomHexId(8),
|
|
214
|
-
|
|
218
|
+
// Ciphertext names are opaque — slicing them would corrupt the IV /
|
|
219
|
+
// tag and make the entry undecryptable. The schema already caps the
|
|
220
|
+
// overall length so the abuse-protection still holds.
|
|
221
|
+
name: body.encryption ? body.author.name : body.author.name.trim().slice(0, LIMITS.AUTHOR_NAME_LENGTH),
|
|
215
222
|
},
|
|
216
223
|
annotations: body.annotations || [],
|
|
217
224
|
...(body.generalNote ? { generalNote: body.generalNote } : {}),
|
|
225
|
+
...(body.encryption ? { encryption: body.encryption } : {}),
|
|
218
226
|
};
|
|
219
227
|
|
|
220
228
|
// KV key is sortable so `since` queries can use prefix listing efficiently.
|
|
@@ -16,8 +16,15 @@ export interface Env {
|
|
|
16
16
|
SHARE_TTL_SECONDS?: string;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
export interface EncryptionMeta {
|
|
20
|
+
alg: "AES-GCM";
|
|
21
|
+
v: 1;
|
|
22
|
+
}
|
|
23
|
+
|
|
19
24
|
export interface SharePayload {
|
|
20
25
|
version: 1;
|
|
26
|
+
/** Present when sessionId/label/compiledJs/sourceJsx are ciphertext. */
|
|
27
|
+
encryption?: EncryptionMeta;
|
|
21
28
|
origin: {
|
|
22
29
|
sessionId: string;
|
|
23
30
|
revision: number;
|
|
@@ -40,6 +47,9 @@ export interface ShareRecord {
|
|
|
40
47
|
componentsVersion: string;
|
|
41
48
|
origin: SharePayload["origin"];
|
|
42
49
|
canvasFilenames: string[];
|
|
50
|
+
/** Mirrored from the SharePayload so consumers (meta endpoint, daemon
|
|
51
|
+
* feedback poller) know whether they're looking at ciphertext fields. */
|
|
52
|
+
encryption?: EncryptionMeta;
|
|
43
53
|
}
|
|
44
54
|
|
|
45
55
|
export interface FeedbackPostBody {
|
|
@@ -56,6 +66,10 @@ export interface FeedbackPostBody {
|
|
|
56
66
|
}>;
|
|
57
67
|
generalNote?: string;
|
|
58
68
|
revision: number;
|
|
69
|
+
/** Present when name/snippet/note/etc. fields are ciphertext. The worker
|
|
70
|
+
* stores the marker verbatim; the daemon uses it to decide whether to
|
|
71
|
+
* decrypt with the share's encryption key. */
|
|
72
|
+
encryption?: EncryptionMeta;
|
|
59
73
|
}
|
|
60
74
|
|
|
61
75
|
export interface FeedbackEntry {
|
|
@@ -66,6 +80,7 @@ export interface FeedbackEntry {
|
|
|
66
80
|
author: { id: string; name: string };
|
|
67
81
|
annotations: FeedbackPostBody["annotations"];
|
|
68
82
|
generalNote?: string;
|
|
83
|
+
encryption?: EncryptionMeta;
|
|
69
84
|
}
|
|
70
85
|
|
|
71
86
|
// --- Limits — single source of truth -------------------------------------
|
|
@@ -77,12 +92,19 @@ export const LIMITS = {
|
|
|
77
92
|
FEEDBACK_BODY_BYTES: 256 * 1024, // 256 KB
|
|
78
93
|
/** Maximum size of an annotation upload. */
|
|
79
94
|
UPLOAD_BYTES: 5 * 1024 * 1024, // 5 MB
|
|
80
|
-
/** Maximum length of a reviewer-supplied display name. */
|
|
95
|
+
/** Maximum length of a reviewer-supplied display name (plaintext mode). */
|
|
81
96
|
AUTHOR_NAME_LENGTH: 80,
|
|
97
|
+
/** Loose upper bound for `author.name` accepted by validation. Plaintext
|
|
98
|
+
* names are sliced down to AUTHOR_NAME_LENGTH in the handler; ciphertext
|
|
99
|
+
* names from encrypted shares need extra headroom for IV + tag + base64
|
|
100
|
+
* overhead and the unknown plaintext length. 1 KB is plenty. */
|
|
101
|
+
AUTHOR_NAME_CIPHERTEXT_LENGTH: 1024,
|
|
82
102
|
/** Maximum number of annotations per feedback submission. */
|
|
83
103
|
MAX_ANNOTATIONS_PER_FEEDBACK: 200,
|
|
84
|
-
/** Maximum length of generalNote markdown. */
|
|
104
|
+
/** Maximum length of generalNote markdown (plaintext mode). */
|
|
85
105
|
GENERAL_NOTE_LENGTH: 10_000,
|
|
106
|
+
/** Loose upper bound for `generalNote` accepted by validation. */
|
|
107
|
+
GENERAL_NOTE_CIPHERTEXT_LENGTH: 32_000,
|
|
86
108
|
/** Default share TTL when SHARE_TTL_SECONDS env var is not set. */
|
|
87
109
|
DEFAULT_SHARE_TTL_SECONDS: 30 * 24 * 60 * 60,
|
|
88
110
|
} as const;
|
|
@@ -11,6 +11,19 @@
|
|
|
11
11
|
import { z } from "zod";
|
|
12
12
|
import { LIMITS } from "./types";
|
|
13
13
|
|
|
14
|
+
// --- Encryption marker ------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
/** Marker on a SharePayload / FeedbackPostBody that tells consumers the
|
|
17
|
+
* associated string fields contain ciphertext rather than plaintext. The
|
|
18
|
+
* worker itself never inspects encrypted contents — it just stores them
|
|
19
|
+
* as opaque strings. The marker exists so the *daemon* (when polling
|
|
20
|
+
* feedback) and the *browser* (when rendering a share) know whether to
|
|
21
|
+
* decrypt. */
|
|
22
|
+
const encryptionSchema = z.object({
|
|
23
|
+
alg: z.literal("AES-GCM"),
|
|
24
|
+
v: z.literal(1),
|
|
25
|
+
});
|
|
26
|
+
|
|
14
27
|
// --- Share creation payload -------------------------------------------------
|
|
15
28
|
|
|
16
29
|
const canvasFileSchema = z.object({
|
|
@@ -26,6 +39,7 @@ const canvasFileSchema = z.object({
|
|
|
26
39
|
|
|
27
40
|
const sharePayloadSchema = z.object({
|
|
28
41
|
version: z.literal(1),
|
|
42
|
+
encryption: encryptionSchema.optional(),
|
|
29
43
|
origin: z.object({
|
|
30
44
|
sessionId: z.string(),
|
|
31
45
|
revision: z.number().int(),
|
|
@@ -63,25 +77,54 @@ const annotationSchema = z.object({
|
|
|
63
77
|
.optional(),
|
|
64
78
|
});
|
|
65
79
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
80
|
+
// In encrypted mode `name` / `generalNote` are ciphertext blobs (base64)
|
|
81
|
+
// and cannot be sensibly length-bound to plaintext limits. The base schema
|
|
82
|
+
// uses the looser ciphertext caps; a post-parse refine then re-applies the
|
|
83
|
+
// strict plaintext bounds when no `encryption` marker is present.
|
|
84
|
+
const feedbackBodySchema = z
|
|
85
|
+
.object({
|
|
86
|
+
author: z.object({
|
|
87
|
+
id: z.string().optional(),
|
|
88
|
+
name: z
|
|
89
|
+
.string()
|
|
90
|
+
.min(1, "author.name is required")
|
|
91
|
+
.max(LIMITS.AUTHOR_NAME_CIPHERTEXT_LENGTH, "author.name too long"),
|
|
92
|
+
}),
|
|
93
|
+
revision: z.number().int(),
|
|
94
|
+
annotations: z
|
|
95
|
+
.array(annotationSchema)
|
|
96
|
+
.max(LIMITS.MAX_ANNOTATIONS_PER_FEEDBACK, `Too many annotations (max ${LIMITS.MAX_ANNOTATIONS_PER_FEEDBACK})`)
|
|
97
|
+
.optional(),
|
|
98
|
+
generalNote: z
|
|
70
99
|
.string()
|
|
71
|
-
.
|
|
72
|
-
.
|
|
73
|
-
|
|
74
|
-
})
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
100
|
+
.max(LIMITS.GENERAL_NOTE_CIPHERTEXT_LENGTH, `generalNote too long`)
|
|
101
|
+
.optional(),
|
|
102
|
+
encryption: encryptionSchema.optional(),
|
|
103
|
+
})
|
|
104
|
+
.superRefine((body, ctx) => {
|
|
105
|
+
if (body.encryption) return;
|
|
106
|
+
if (body.author.name.trim().length === 0) {
|
|
107
|
+
ctx.addIssue({
|
|
108
|
+
code: z.ZodIssueCode.custom,
|
|
109
|
+
path: ["author", "name"],
|
|
110
|
+
message: "author.name is required",
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
if (body.author.name.length > LIMITS.AUTHOR_NAME_LENGTH) {
|
|
114
|
+
ctx.addIssue({
|
|
115
|
+
code: z.ZodIssueCode.custom,
|
|
116
|
+
path: ["author", "name"],
|
|
117
|
+
message: `author.name must be ≤ ${LIMITS.AUTHOR_NAME_LENGTH} chars`,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
if (body.generalNote && body.generalNote.length > LIMITS.GENERAL_NOTE_LENGTH) {
|
|
121
|
+
ctx.addIssue({
|
|
122
|
+
code: z.ZodIssueCode.custom,
|
|
123
|
+
path: ["generalNote"],
|
|
124
|
+
message: `generalNote too long (max ${LIMITS.GENERAL_NOTE_LENGTH} chars)`,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
});
|
|
85
128
|
|
|
86
129
|
export type FeedbackBodyParsed = z.infer<typeof feedbackBodySchema>;
|
|
87
130
|
|