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.
@@ -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
- name: body.author.name.slice(0, LIMITS.AUTHOR_NAME_LENGTH),
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
- const feedbackBodySchema = z.object({
67
- author: z.object({
68
- id: z.string().optional(),
69
- name: z
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
- .trim()
72
- .min(1, "author.name is required")
73
- .max(LIMITS.AUTHOR_NAME_LENGTH, `author.name must be ≤ ${LIMITS.AUTHOR_NAME_LENGTH} chars`),
74
- }),
75
- revision: z.number().int(),
76
- annotations: z
77
- .array(annotationSchema)
78
- .max(LIMITS.MAX_ANNOTATIONS_PER_FEEDBACK, `Too many annotations (max ${LIMITS.MAX_ANNOTATIONS_PER_FEEDBACK})`)
79
- .optional(),
80
- generalNote: z
81
- .string()
82
- .max(LIMITS.GENERAL_NOTE_LENGTH, `generalNote too long (max ${LIMITS.GENERAL_NOTE_LENGTH} chars)`)
83
- .optional(),
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