@agent-native/core 0.92.5 → 0.92.6

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.
Files changed (147) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +13 -0
  3. package/corpus/core/docs/content/template-assets.mdx +139 -0
  4. package/corpus/core/package.json +1 -1
  5. package/corpus/core/src/agent/production-agent.ts +96 -6
  6. package/corpus/core/src/client/AssistantChat.tsx +127 -15
  7. package/corpus/core/src/client/chat/tool-call-display.tsx +9 -3
  8. package/corpus/core/src/client/org/TeamPage.tsx +274 -177
  9. package/corpus/core/src/client/settings/DemoModeSection.tsx +6 -6
  10. package/corpus/core/src/client/settings/SettingsPanel.tsx +1 -1
  11. package/corpus/core/src/client/settings/SettingsTabsPage.tsx +49 -1
  12. package/corpus/core/src/demo/actions/toggle-demo-mode.ts +2 -2
  13. package/corpus/core/src/demo/config.ts +3 -3
  14. package/corpus/core/src/demo/redact.ts +720 -0
  15. package/corpus/core/src/extensions/url-safety.ts +13 -1
  16. package/corpus/core/src/localization/default-messages.ts +7 -0
  17. package/corpus/core/src/templates/default/app/i18n/ar-SA.ts +7 -0
  18. package/corpus/core/src/templates/default/app/i18n/de-DE.ts +7 -0
  19. package/corpus/core/src/templates/default/app/i18n/en-US.ts +7 -0
  20. package/corpus/core/src/templates/default/app/i18n/es-ES.ts +7 -0
  21. package/corpus/core/src/templates/default/app/i18n/fr-FR.ts +7 -0
  22. package/corpus/core/src/templates/default/app/i18n/hi-IN.ts +7 -0
  23. package/corpus/core/src/templates/default/app/i18n/ja-JP.ts +7 -0
  24. package/corpus/core/src/templates/default/app/i18n/ko-KR.ts +7 -0
  25. package/corpus/core/src/templates/default/app/i18n/pt-BR.ts +7 -0
  26. package/corpus/core/src/templates/default/app/i18n/zh-CN.ts +7 -0
  27. package/corpus/core/src/templates/default/app/i18n/zh-TW.ts +7 -0
  28. package/corpus/templates/analytics/AGENTS.md +6 -3
  29. package/corpus/templates/analytics/changelog/2026-07-09-uptime-monitors-use-scheduled-workers-in-production-serverless.md +6 -0
  30. package/corpus/templates/analytics/docs/uptime-monitoring.md +5 -0
  31. package/corpus/templates/analytics/server/plugins/uptime-monitor-jobs.ts +14 -2
  32. package/corpus/templates/assets/.agents/skills/image-generation/SKILL.md +35 -0
  33. package/corpus/templates/assets/.agents/skills/library-management/SKILL.md +19 -0
  34. package/corpus/templates/assets/AGENTS.md +13 -0
  35. package/corpus/templates/assets/actions/_generation-preset-settings.ts +73 -0
  36. package/corpus/templates/assets/actions/_preset-skeleton-validation.ts +63 -0
  37. package/corpus/templates/assets/actions/create-generation-preset.ts +22 -8
  38. package/corpus/templates/assets/actions/duplicate-library.ts +49 -1
  39. package/corpus/templates/assets/actions/generate-image-batch.ts +14 -1
  40. package/corpus/templates/assets/actions/generate-image.ts +195 -26
  41. package/corpus/templates/assets/actions/import-asset-from-url.ts +323 -0
  42. package/corpus/templates/assets/actions/list-generation-presets.ts +1 -1
  43. package/corpus/templates/assets/actions/rerun-generation-run.ts +37 -0
  44. package/corpus/templates/assets/actions/update-generation-preset.ts +19 -1
  45. package/corpus/templates/assets/app/i18n/ar-SA.ts +1 -0
  46. package/corpus/templates/assets/app/i18n/de-DE.ts +1 -0
  47. package/corpus/templates/assets/app/i18n/es-ES.ts +1 -0
  48. package/corpus/templates/assets/app/i18n/fr-FR.ts +1 -0
  49. package/corpus/templates/assets/app/i18n/hi-IN.ts +1 -0
  50. package/corpus/templates/assets/app/i18n/ja-JP.ts +1 -0
  51. package/corpus/templates/assets/app/i18n/ko-KR.ts +1 -0
  52. package/corpus/templates/assets/app/i18n/pt-BR.ts +1 -0
  53. package/corpus/templates/assets/app/i18n/zh-CN.ts +1 -0
  54. package/corpus/templates/assets/app/i18n/zh-TW.ts +41 -0
  55. package/corpus/templates/assets/app/i18n-data.ts +93 -0
  56. package/corpus/templates/assets/app/routes/brand-kits.$id.tsx +66 -11
  57. package/corpus/templates/assets/app/routes/brand-kits.$id_.presets.$presetId.tsx +959 -24
  58. package/corpus/templates/assets/changelog/2026-07-07-preset-skeleton-controls-fit-correctly-on-narrower-edit-page.md +6 -0
  59. package/corpus/templates/assets/changelog/2026-07-09-import-reference-images-from-url.md +6 -0
  60. package/corpus/templates/assets/changelog/2026-07-09-preset-reference-board.md +6 -0
  61. package/corpus/templates/assets/server/handlers/assets.ts +7 -27
  62. package/corpus/templates/assets/server/lib/generation.ts +83 -2
  63. package/corpus/templates/assets/server/lib/preset-chat-context.ts +17 -4
  64. package/corpus/templates/assets/server/lib/preset-references.ts +182 -0
  65. package/corpus/templates/assets/server/lib/upload-validation.ts +38 -0
  66. package/corpus/templates/assets/shared/api.ts +20 -0
  67. package/corpus/templates/calendar/.agents/skills/event-management/SKILL.md +6 -0
  68. package/corpus/templates/calendar/actions/create-event.ts +8 -2
  69. package/corpus/templates/calendar/app/components/calendar/CreateEventDialog.tsx +22 -7
  70. package/corpus/templates/calendar/changelog/2026-07-09-calendar-events-with-guests-now-automatically-get-a-google-m.md +6 -0
  71. package/corpus/templates/calendar/changelog/2026-07-09-new-events-now-mark-the-creator-rsvp-as-yes.md +6 -0
  72. package/corpus/templates/calendar/server/handlers/events.ts +9 -2
  73. package/corpus/templates/calendar/server/lib/event-video-conferencing.ts +58 -0
  74. package/corpus/templates/calendar/server/lib/google-calendar.ts +1 -0
  75. package/corpus/templates/clips/changelog/2026-07-09-meeting-reminder-popovers-can-now-be-dismissed-with-a-top-le.md +6 -0
  76. package/corpus/templates/clips/desktop/src/overlays/meeting-notification.tsx +3 -1
  77. package/corpus/templates/clips/desktop/src/styles.css +13 -9
  78. package/corpus/templates/clips/desktop/src-tauri/src/notifications.rs +5 -2
  79. package/dist/agent/production-agent.d.ts +34 -1
  80. package/dist/agent/production-agent.d.ts.map +1 -1
  81. package/dist/agent/production-agent.js +58 -5
  82. package/dist/agent/production-agent.js.map +1 -1
  83. package/dist/client/AssistantChat.d.ts +3 -1
  84. package/dist/client/AssistantChat.d.ts.map +1 -1
  85. package/dist/client/AssistantChat.js +96 -15
  86. package/dist/client/AssistantChat.js.map +1 -1
  87. package/dist/client/chat/tool-call-display.d.ts.map +1 -1
  88. package/dist/client/chat/tool-call-display.js +5 -1
  89. package/dist/client/chat/tool-call-display.js.map +1 -1
  90. package/dist/client/org/TeamPage.d.ts.map +1 -1
  91. package/dist/client/org/TeamPage.js +32 -13
  92. package/dist/client/org/TeamPage.js.map +1 -1
  93. package/dist/client/settings/DemoModeSection.d.ts +3 -3
  94. package/dist/client/settings/DemoModeSection.js +4 -4
  95. package/dist/client/settings/DemoModeSection.js.map +1 -1
  96. package/dist/client/settings/SettingsPanel.js +1 -1
  97. package/dist/client/settings/SettingsPanel.js.map +1 -1
  98. package/dist/client/settings/SettingsTabsPage.d.ts.map +1 -1
  99. package/dist/client/settings/SettingsTabsPage.js +38 -2
  100. package/dist/client/settings/SettingsTabsPage.js.map +1 -1
  101. package/dist/collab/awareness.d.ts +2 -2
  102. package/dist/collab/awareness.d.ts.map +1 -1
  103. package/dist/demo/actions/toggle-demo-mode.js +2 -2
  104. package/dist/demo/actions/toggle-demo-mode.js.map +1 -1
  105. package/dist/demo/config.js +3 -3
  106. package/dist/demo/config.js.map +1 -1
  107. package/dist/demo/redact.d.ts +1 -1
  108. package/dist/demo/redact.d.ts.map +1 -1
  109. package/dist/demo/redact.js +0 -0
  110. package/dist/demo/redact.js.map +1 -1
  111. package/dist/extensions/url-safety.d.ts +5 -0
  112. package/dist/extensions/url-safety.d.ts.map +1 -1
  113. package/dist/extensions/url-safety.js +10 -0
  114. package/dist/extensions/url-safety.js.map +1 -1
  115. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  116. package/dist/localization/default-messages.d.ts +7 -0
  117. package/dist/localization/default-messages.d.ts.map +1 -1
  118. package/dist/localization/default-messages.js +7 -0
  119. package/dist/localization/default-messages.js.map +1 -1
  120. package/dist/observability/routes.d.ts +5 -5
  121. package/dist/progress/routes.d.ts +1 -1
  122. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  123. package/dist/resources/handlers.d.ts +1 -1
  124. package/dist/templates/default/app/i18n/ar-SA.ts +7 -0
  125. package/dist/templates/default/app/i18n/de-DE.ts +7 -0
  126. package/dist/templates/default/app/i18n/en-US.ts +7 -0
  127. package/dist/templates/default/app/i18n/es-ES.ts +7 -0
  128. package/dist/templates/default/app/i18n/fr-FR.ts +7 -0
  129. package/dist/templates/default/app/i18n/hi-IN.ts +7 -0
  130. package/dist/templates/default/app/i18n/ja-JP.ts +7 -0
  131. package/dist/templates/default/app/i18n/ko-KR.ts +7 -0
  132. package/dist/templates/default/app/i18n/pt-BR.ts +7 -0
  133. package/dist/templates/default/app/i18n/zh-CN.ts +7 -0
  134. package/dist/templates/default/app/i18n/zh-TW.ts +7 -0
  135. package/docs/content/template-assets.mdx +139 -0
  136. package/package.json +1 -1
  137. package/src/templates/default/app/i18n/ar-SA.ts +7 -0
  138. package/src/templates/default/app/i18n/de-DE.ts +7 -0
  139. package/src/templates/default/app/i18n/en-US.ts +7 -0
  140. package/src/templates/default/app/i18n/es-ES.ts +7 -0
  141. package/src/templates/default/app/i18n/fr-FR.ts +7 -0
  142. package/src/templates/default/app/i18n/hi-IN.ts +7 -0
  143. package/src/templates/default/app/i18n/ja-JP.ts +7 -0
  144. package/src/templates/default/app/i18n/ko-KR.ts +7 -0
  145. package/src/templates/default/app/i18n/pt-BR.ts +7 -0
  146. package/src/templates/default/app/i18n/zh-CN.ts +7 -0
  147. package/src/templates/default/app/i18n/zh-TW.ts +7 -0
@@ -0,0 +1,720 @@
1
+ /**
2
+ * Pure, dependency-free, deterministic demo-mode redactor.
3
+ *
4
+ * Replaces sensitive values (names, emails, and free numbers) with stable fake
5
+ * substitutes so a demo looks coherent — the same input always maps to the
6
+ * same fake. Crucially, it NEVER rewrites identifiers, structural tokens, or
7
+ * timestamps: under-redaction is safe, corrupting an ID is not. The string
8
+ * redactor uses a protect-first strategy (mask IDs with opaque placeholders
9
+ * before any transform runs, restore them byte-identical afterwards), and the
10
+ * structure-aware walker additionally protects leaf values by key name.
11
+ */
12
+
13
+ export interface RedactOptions {
14
+ salt?: string;
15
+ }
16
+
17
+ /* ------------------------------------------------------------------ *
18
+ * Seeded hash + PRNG (xmur3 seed → mulberry32 stream)
19
+ * ------------------------------------------------------------------ */
20
+
21
+ function xmur3(str: string): () => number {
22
+ let h = 1779033703 ^ str.length;
23
+ for (let i = 0; i < str.length; i++) {
24
+ h = Math.imul(h ^ str.charCodeAt(i), 3432918353);
25
+ h = (h << 13) | (h >>> 19);
26
+ }
27
+ return function () {
28
+ h = Math.imul(h ^ (h >>> 16), 2246822507);
29
+ h = Math.imul(h ^ (h >>> 13), 3266489909);
30
+ h ^= h >>> 16;
31
+ return h >>> 0;
32
+ };
33
+ }
34
+
35
+ function mulberry32(seed: number): () => number {
36
+ let a = seed >>> 0;
37
+ return function () {
38
+ a = (a + 0x6d2b79f5) >>> 0;
39
+ let t = a;
40
+ t = Math.imul(t ^ (t >>> 15), t | 1);
41
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
42
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
43
+ };
44
+ }
45
+
46
+ /** Deterministic PRNG seeded by `value + salt`. */
47
+ function seededRng(value: string, salt: string): () => number {
48
+ const seedFn = xmur3(`${value}${salt}`);
49
+ return mulberry32(seedFn());
50
+ }
51
+
52
+ function pick<T>(rng: () => number, pool: readonly T[]): T {
53
+ return pool[Math.floor(rng() * pool.length) % pool.length];
54
+ }
55
+
56
+ /* ------------------------------------------------------------------ *
57
+ * Stable mapping cache (bounded, TTL, leak-free)
58
+ *
59
+ * The fake values are already a pure deterministic function of
60
+ * (kind, salt, original), so identical input is always stable. This cache
61
+ * adds two things on top:
62
+ *
63
+ * 1. A consistent forward map so the same original keeps the same fake for
64
+ * the life of the entry, even if the algorithm/salt is ever tuned.
65
+ * 2. Idempotency: every fake we emit is remembered, so when a fake value
66
+ * round-trips back through redaction (e.g. you edit a draft that's
67
+ * already showing fake text, it autosaves, then refetches) it is passed
68
+ * through UNCHANGED instead of being re-faked into something new. This
69
+ * is what stops names/emails drifting on every edit.
70
+ *
71
+ * Leak-free by construction: no timers, a hard size cap, and lazy TTL purge
72
+ * on write. Works the same per-tab in the browser and per-process on the
73
+ * server. Memoizing a pure function across users is safe — output depends
74
+ * only on input.
75
+ * ------------------------------------------------------------------ */
76
+
77
+ const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
78
+ const CACHE_MAX = 5000;
79
+
80
+ // original-key → { fake, at }. Insertion-ordered (Map) so the oldest key is
81
+ // first — used for cap eviction.
82
+ const forwardCache = new Map<string, { value: string; at: number }>();
83
+ // produced fake → last-seen timestamp (same bound/TTL policy).
84
+ const producedFakes = new Map<string, number>();
85
+
86
+ function purge(map: Map<string, { at: number } | number>): void {
87
+ const now = Date.now();
88
+ for (const [k, v] of map) {
89
+ const at = typeof v === "number" ? v : v.at;
90
+ if (now - at > CACHE_TTL_MS) map.delete(k);
91
+ else break; // insertion-ordered: first fresh entry ⇒ rest are fresher
92
+ }
93
+ while (map.size > CACHE_MAX) {
94
+ const oldest = map.keys().next().value as string | undefined;
95
+ if (oldest === undefined) break;
96
+ map.delete(oldest);
97
+ }
98
+ }
99
+
100
+ function rememberProduced(fake: string): string {
101
+ if (fake.length === 0) return fake;
102
+ producedFakes.delete(fake);
103
+ producedFakes.set(fake, Date.now());
104
+ if (producedFakes.size > CACHE_MAX) purge(producedFakes);
105
+ return fake;
106
+ }
107
+
108
+ function isProducedFake(value: string): boolean {
109
+ const at = producedFakes.get(value);
110
+ if (at === undefined) return false;
111
+ if (Date.now() - at > CACHE_TTL_MS) {
112
+ producedFakes.delete(value);
113
+ return false;
114
+ }
115
+ return true;
116
+ }
117
+
118
+ /**
119
+ * Memoize a deterministic fake by (kind, salt, original). On a hit, the
120
+ * entry's recency is refreshed (LRU-ish). The generated value is also
121
+ * registered as a produced fake so it survives a round-trip unchanged.
122
+ */
123
+ function memoFake(
124
+ kind: string,
125
+ original: string,
126
+ salt: string,
127
+ gen: () => string,
128
+ // Numbers opt out: a fake number collides with real numbers far too often
129
+ // to safely treat "looks like one we emitted" as "leave it alone".
130
+ idempotent = true,
131
+ ): string {
132
+ // Already one of our fakes? Leave it exactly as-is (round-trip stable).
133
+ if (idempotent && isProducedFake(original)) return original;
134
+
135
+ const key = `${kind}${salt}${original}`;
136
+ const hit = forwardCache.get(key);
137
+ const now = Date.now();
138
+ if (hit && now - hit.at <= CACHE_TTL_MS) {
139
+ forwardCache.delete(key);
140
+ forwardCache.set(key, { value: hit.value, at: now });
141
+ return hit.value;
142
+ }
143
+
144
+ const value = gen();
145
+ forwardCache.set(key, { value, at: now });
146
+ if (forwardCache.size > CACHE_MAX) purge(forwardCache);
147
+ if (idempotent) rememberProduced(value);
148
+ return value;
149
+ }
150
+
151
+ /* ------------------------------------------------------------------ *
152
+ * Curated name pools
153
+ * ------------------------------------------------------------------ */
154
+
155
+ const FIRST_NAMES: readonly string[] = [
156
+ "Jane",
157
+ "John",
158
+ "Alex",
159
+ "Maria",
160
+ "Sam",
161
+ "Olivia",
162
+ "Liam",
163
+ "Emma",
164
+ "Noah",
165
+ "Ava",
166
+ "Lucas",
167
+ "Mia",
168
+ "Ethan",
169
+ "Sofia",
170
+ "Mason",
171
+ "Isla",
172
+ "Leo",
173
+ "Aria",
174
+ "Henry",
175
+ "Zoe",
176
+ "Owen",
177
+ "Nora",
178
+ "Caleb",
179
+ "Lily",
180
+ "Ryan",
181
+ "Ruby",
182
+ "Adam",
183
+ "Chloe",
184
+ "Eli",
185
+ "Hazel",
186
+ "Max",
187
+ "Iris",
188
+ "Ben",
189
+ "Clara",
190
+ "Theo",
191
+ "Maya",
192
+ "Felix",
193
+ "Elena",
194
+ "Jonah",
195
+ "Greta",
196
+ ];
197
+
198
+ const LAST_NAMES: readonly string[] = [
199
+ "Doe",
200
+ "Smith",
201
+ "Johnson",
202
+ "Lee",
203
+ "Brown",
204
+ "Garcia",
205
+ "Miller",
206
+ "Davis",
207
+ "Wilson",
208
+ "Moore",
209
+ "Taylor",
210
+ "Anderson",
211
+ "Thomas",
212
+ "Jackson",
213
+ "White",
214
+ "Harris",
215
+ "Martin",
216
+ "Clark",
217
+ "Lewis",
218
+ "Walker",
219
+ "Hall",
220
+ "Young",
221
+ "King",
222
+ "Wright",
223
+ "Hill",
224
+ "Green",
225
+ "Adams",
226
+ "Baker",
227
+ "Nelson",
228
+ "Carter",
229
+ "Mitchell",
230
+ "Perez",
231
+ "Roberts",
232
+ "Turner",
233
+ "Phillips",
234
+ "Campbell",
235
+ "Parker",
236
+ "Evans",
237
+ "Edwards",
238
+ "Collins",
239
+ ];
240
+
241
+ /* ------------------------------------------------------------------ *
242
+ * Fake value generators (deterministic in value + salt)
243
+ * ------------------------------------------------------------------ */
244
+
245
+ function fakeName(original: string, salt: string): string {
246
+ return memoFake("name", original, salt, () => {
247
+ const rng = seededRng(`name:${original}`, salt);
248
+ const first = pick(rng, FIRST_NAMES);
249
+ const last = pick(rng, LAST_NAMES);
250
+ return `${first} ${last}`;
251
+ });
252
+ }
253
+
254
+ // Realistic-looking but safe stand-in domains: real consumer providers (the
255
+ // local part is fake, so no real mailbox is implied) plus the canonical
256
+ // reserved fictional-company domains (Contoso/Fabrikam/Northwind), which read
257
+ // as real businesses but are intentional placeholders. No example.com.
258
+ const FAKE_EMAIL_DOMAINS: readonly string[] = [
259
+ "gmail.com",
260
+ "outlook.com",
261
+ "icloud.com",
262
+ "yahoo.com",
263
+ "proton.me",
264
+ "hey.com",
265
+ "fastmail.com",
266
+ "contoso.com",
267
+ "fabrikam.com",
268
+ "northwind.com",
269
+ "acme.io",
270
+ "globex.net",
271
+ ];
272
+
273
+ function fakeEmail(original: string, salt: string): string {
274
+ return memoFake("email", original.toLowerCase(), salt, () => {
275
+ const rng = seededRng(`email:${original.toLowerCase()}`, salt);
276
+ const first = pick(rng, FIRST_NAMES).toLowerCase();
277
+ const last = pick(rng, LAST_NAMES).toLowerCase();
278
+ const domain = pick(rng, FAKE_EMAIL_DOMAINS);
279
+ // Vary the local part so addresses don't all look templated.
280
+ const shape = Math.floor(rng() * 6);
281
+ let local: string;
282
+ switch (shape) {
283
+ case 0:
284
+ local = `${first}.${last}`;
285
+ break;
286
+ case 1:
287
+ local = `${first}${last}`;
288
+ break;
289
+ case 2:
290
+ local = `${first[0]}${last}`;
291
+ break;
292
+ case 3:
293
+ local = `${first}.${last}${1 + Math.floor(rng() * 89)}`;
294
+ break;
295
+ case 4:
296
+ local = `${first}_${last}`;
297
+ break;
298
+ default:
299
+ local = `${last}.${first}`;
300
+ break;
301
+ }
302
+ return `${local}@${domain}`;
303
+ });
304
+ }
305
+
306
+ /**
307
+ * Replace every digit in `numericBody` with a freshly-generated digit while
308
+ * preserving non-digit characters (grouping commas, decimal points) exactly.
309
+ * The first digit is forced non-zero so the digit count is observable.
310
+ */
311
+ function fakeNumberBody(
312
+ numericBody: string,
313
+ original: string,
314
+ salt: string,
315
+ ): string {
316
+ // Stable per original token, but NOT registered as a produced fake — a
317
+ // fake number coincides with real numbers too often to skip on round-trip.
318
+ return memoFake(
319
+ "num",
320
+ original,
321
+ salt,
322
+ () => {
323
+ const rng = seededRng(`num:${original}`, salt);
324
+ let out = "";
325
+ let seenDigit = false;
326
+ for (const ch of numericBody) {
327
+ if (ch >= "0" && ch <= "9") {
328
+ let d: number;
329
+ if (!seenDigit) {
330
+ // Leading digit: 1-9 so length is preserved.
331
+ d = 1 + Math.floor(rng() * 9);
332
+ seenDigit = true;
333
+ } else {
334
+ d = Math.floor(rng() * 10);
335
+ }
336
+ out += String(d);
337
+ } else {
338
+ out += ch;
339
+ }
340
+ }
341
+ return out;
342
+ },
343
+ false,
344
+ );
345
+ }
346
+
347
+ /* ------------------------------------------------------------------ *
348
+ * Protect-first tokenizer (ID-safety core)
349
+ * ------------------------------------------------------------------ */
350
+
351
+ /**
352
+ * Patterns whose matches must be protected from ANY transform. Order matters:
353
+ * the most structural / specific shapes come first so they win the scan.
354
+ */
355
+ const PROTECT_PATTERNS: RegExp[] = [
356
+ // URLs / URIs with a scheme.
357
+ /\b[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s]+/g,
358
+ /\b(?:mailto|data|tel|urn):[^\s]+/gi,
359
+ // UUID.
360
+ /\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b/g,
361
+ // JWT — three base64url segments separated by dots.
362
+ /\b[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b/g,
363
+ // ISO datetime / date.
364
+ /\b\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?Z?)?\b/g,
365
+ // Bare clock time.
366
+ /\b\d{1,2}:\d{2}(?::\d{2})?\b/g,
367
+ // Path-like tokens containing a slash (but not bare prose with slashes —
368
+ // require no spaces and at least one slash with adjacent non-space).
369
+ /(?:\S*\/\S+)+/g,
370
+ // nanoid / hex / base64-ish blobs: an unbroken [A-Za-z0-9_-] run that is
371
+ // either long-with-a-digit, mixes letters AND digits at length >= 10, or
372
+ // contains a `_`/`-` inside the run (real names/numbers never look so).
373
+ /[A-Za-z0-9_-]+/g,
374
+ ];
375
+
376
+ const PLACEHOLDER_PREFIX = "P";
377
+ const PLACEHOLDER_SUFFIX = "";
378
+
379
+ function makePlaceholder(index: number): string {
380
+ return `${PLACEHOLDER_PREFIX}${index}${PLACEHOLDER_SUFFIX}`;
381
+ }
382
+
383
+ /** True if a `[A-Za-z0-9_-]+` run is identifier-shaped (must be protected). */
384
+ function looksLikeIdentifierToken(tok: string): boolean {
385
+ if (tok.length < 3) return false;
386
+ const hasLetter = /[A-Za-z]/.test(tok);
387
+ const hasDigit = /[0-9]/.test(tok);
388
+ const hasSep = /[_-]/.test(tok);
389
+
390
+ // A pure number (optionally with separators handled elsewhere) is NOT an
391
+ // identifier here — the number rule handles those.
392
+ if (!hasLetter && !hasSep) return false;
393
+
394
+ // Long hex/base64-ish blob with a digit.
395
+ if (tok.length >= 16 && hasDigit) return true;
396
+ // nanoid-ish: length >= 10 mixing letters AND digits.
397
+ if (tok.length >= 10 && hasLetter && hasDigit) return true;
398
+ // Any token that has a `_`/`-` joined inside an unbroken run AND also
399
+ // contains a digit or is long — e.g. `order-2024-abc`, `api_key_v2`.
400
+ if (hasSep && (hasDigit || tok.length >= 10)) return true;
401
+ // Mixed letters+digits adjacency (e.g. `abc123`, `v2`, `step3`) — protect so
402
+ // the number rule never bites an embedded number.
403
+ if (hasLetter && hasDigit) return true;
404
+
405
+ return false;
406
+ }
407
+
408
+ interface Protection {
409
+ text: string;
410
+ restore: Map<string, string>;
411
+ }
412
+
413
+ /**
414
+ * Walk `text`, replace every protected substring with an opaque placeholder,
415
+ * and return the masked text plus a restore map. Non-overlapping, left-to-right
416
+ * earliest-match-wins so a transform literally cannot see a protected value.
417
+ */
418
+ function protect(text: string): Protection {
419
+ const restore = new Map<string, string>();
420
+ let counter = 0;
421
+
422
+ // Collect all candidate matches across patterns, then resolve overlaps by
423
+ // earliest start (and longest on tie).
424
+ interface Span {
425
+ start: number;
426
+ end: number;
427
+ value: string;
428
+ }
429
+ const spans: Span[] = [];
430
+
431
+ for (let p = 0; p < PROTECT_PATTERNS.length; p++) {
432
+ const re = new RegExp(
433
+ PROTECT_PATTERNS[p].source,
434
+ PROTECT_PATTERNS[p].flags,
435
+ );
436
+ let m: RegExpExecArray | null;
437
+ while ((m = re.exec(text)) !== null) {
438
+ const value = m[0];
439
+ if (value.length === 0) {
440
+ re.lastIndex++;
441
+ continue;
442
+ }
443
+ const isTokenPattern = p === PROTECT_PATTERNS.length - 1;
444
+ if (isTokenPattern && !looksLikeIdentifierToken(value)) {
445
+ continue;
446
+ }
447
+ spans.push({ start: m.index, end: m.index + value.length, value });
448
+ }
449
+ }
450
+
451
+ if (spans.length === 0) return { text, restore };
452
+
453
+ spans.sort((a, b) =>
454
+ a.start !== b.start ? a.start - b.start : b.end - a.end,
455
+ );
456
+
457
+ let out = "";
458
+ let cursor = 0;
459
+ for (const span of spans) {
460
+ if (span.start < cursor) continue; // overlapped by an earlier protection
461
+ out += text.slice(cursor, span.start);
462
+ const ph = makePlaceholder(counter++);
463
+ restore.set(ph, span.value);
464
+ out += ph;
465
+ cursor = span.end;
466
+ }
467
+ out += text.slice(cursor);
468
+
469
+ return { text: out, restore };
470
+ }
471
+
472
+ function unprotect(text: string, restore: Map<string, string>): string {
473
+ if (restore.size === 0) return text;
474
+ let out = text;
475
+ for (const [ph, original] of restore) {
476
+ out = out.split(ph).join(original);
477
+ }
478
+ return out;
479
+ }
480
+
481
+ /* ------------------------------------------------------------------ *
482
+ * Transforms (run only on protected text)
483
+ * ------------------------------------------------------------------ */
484
+
485
+ const EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g;
486
+
487
+ // 2+ capitalized words (each starts uppercase, allows internal lowercase /
488
+ // apostrophes / hyphens). Single-letter middle initials allowed: `Sarah J Connor`.
489
+ const FULL_NAME_RE = /\b[A-Z][a-zA-Z'’-]*(?:\s+[A-Z](?:[a-zA-Z'’-]*)?){1,3}\b/g;
490
+
491
+ // A standalone numeric token: optional currency + sign, digits with optional
492
+ // comma grouping and a single optional decimal part. Bounded so it is NOT
493
+ // adjacent to a letter (placeholders already removed letter-mixed tokens, but
494
+ // this keeps the rule self-contained and safe on raw structured values too).
495
+ const NUMBER_RE =
496
+ /(^|[^A-Za-z0-9_])([$€£]?)([+-]?)(\d[\d,]*(?:\.\d+)?)(?![A-Za-z0-9_])/g;
497
+
498
+ function transformEmails(text: string, salt: string): string {
499
+ return text.replace(EMAIL_RE, (match) => fakeEmail(match, salt));
500
+ }
501
+
502
+ function transformNames(text: string, salt: string): string {
503
+ return text.replace(FULL_NAME_RE, (match) => fakeName(match, salt));
504
+ }
505
+
506
+ function isProbablyYear(digits: string): boolean {
507
+ if (digits.length !== 4) return false;
508
+ const n = Number(digits);
509
+ return n >= 1900 && n <= 2099;
510
+ }
511
+
512
+ function transformNumbers(text: string, salt: string): string {
513
+ return text.replace(
514
+ NUMBER_RE,
515
+ (_full, pre: string, currency: string, sign: string, body: string) => {
516
+ const hasGrouping = body.includes(",");
517
+ const hasDecimal = body.includes(".");
518
+ const digitsOnly = body.replace(/[^0-9]/g, "");
519
+
520
+ // Skip bare years like 2026.
521
+ if (
522
+ !currency &&
523
+ !sign &&
524
+ !hasGrouping &&
525
+ !hasDecimal &&
526
+ isProbablyYear(digitsOnly)
527
+ ) {
528
+ return `${pre}${currency}${sign}${body}`;
529
+ }
530
+
531
+ // Skip standalone integers < 1000 with no currency and no grouping
532
+ // (rewriting "3 unread" / "page 2" looks broken and isn't sensitive).
533
+ if (!currency && !hasGrouping && !hasDecimal) {
534
+ const n = Number(digitsOnly);
535
+ if (Number.isFinite(n) && n < 1000) {
536
+ return `${pre}${currency}${sign}${body}`;
537
+ }
538
+ }
539
+
540
+ const fakeBody = fakeNumberBody(body, `${currency}${sign}${body}`, salt);
541
+ return `${pre}${currency}${sign}${fakeBody}`;
542
+ },
543
+ );
544
+ }
545
+
546
+ /* ------------------------------------------------------------------ *
547
+ * Public: string redactor
548
+ * ------------------------------------------------------------------ */
549
+
550
+ function redactDemoStringInternal(
551
+ text: string,
552
+ salt: string,
553
+ redactNames: boolean,
554
+ ): string {
555
+ if (typeof text !== "string" || text.length === 0) return text;
556
+
557
+ const { text: masked, restore } = protect(text);
558
+ let out = masked;
559
+ out = transformEmails(out, salt);
560
+ if (redactNames) out = transformNames(out, salt);
561
+ out = transformNumbers(out, salt);
562
+ return unprotect(out, restore);
563
+ }
564
+
565
+ export function redactDemoString(text: string, opts?: RedactOptions): string {
566
+ return redactDemoStringInternal(text, opts?.salt ?? "", true);
567
+ }
568
+
569
+ /* ------------------------------------------------------------------ *
570
+ * Public: structure-aware redactor
571
+ * ------------------------------------------------------------------ */
572
+
573
+ // Keys whose leaf values must NEVER be transformed (still recurse into nested
574
+ // objects/arrays under them). Besides ids/urls/timestamps this also covers
575
+ // code/query-bearing keys (`sql`, `query`, `expression`, `formula`, `code`):
576
+ // prose-redacting a SQL string injects fake emails/numbers into it and rewrites
577
+ // its leading tokens, producing `syntax error at or near "Henry"` /
578
+ // `queries must start with SELECT or WITH`. The query must run untouched; its
579
+ // RESULTS are what get redacted so the chart still shows fake values.
580
+ const PROTECTED_KEY_RE =
581
+ /^id$|(^|_)id$|Id$|Ids$|uuid|guid|slug|token|secret|password|passwd|apikey|api_key|hash|sha\d*|etag|cursor|nonce|sessionid|messageid|threadid|nodeid|(^|_)key$|keyid|(^|_)ref$|url$|uri$|href$|src$|path$|filename$|mimetype|mime|^sql$|sql$|query|expression|formula|^code$|createdat|updatedat|deletedat|expiresat|timestamp|.+at$|.+_at$/i;
582
+
583
+ // UI labels and chart/dashboard titles often look like person names ("Maya
584
+ // Davis (First-party)", "Clicks by Henry Moore") but are structural labels. Keep
585
+ // them stable while still redacting free-text and contact-style person fields.
586
+ const LABEL_TEXT_KEY_RE = /^(name|title|label)$/i;
587
+
588
+ const MAX_DEPTH = 64;
589
+
590
+ function isStandalonePersonNameLabel(text: string): boolean {
591
+ const trimmed = text.trim();
592
+ if (!trimmed) return false;
593
+ return /^[A-Z][a-zA-Z'’-]+(?:\s+[A-Z]\.?)?\s+[A-Z][a-zA-Z'’-]+$/.test(
594
+ trimmed,
595
+ );
596
+ }
597
+
598
+ function shouldRedactNamesForChild(key: string, entry: unknown): boolean {
599
+ if (!LABEL_TEXT_KEY_RE.test(key)) return true;
600
+ // Preserve structural labels/titles, but do not let common contact-shaped
601
+ // fields like `{ name: "Olivia Parker" }` leak real person names.
602
+ return typeof entry === "string" ? isStandalonePersonNameLabel(entry) : true;
603
+ }
604
+
605
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
606
+ if (value === null || typeof value !== "object") return false;
607
+ if (Array.isArray(value)) return false;
608
+ if (value instanceof Date) return false;
609
+ const proto = Object.getPrototypeOf(value);
610
+ return proto === Object.prototype || proto === null;
611
+ }
612
+
613
+ function redactDemoStringWithSalt(
614
+ text: string,
615
+ salt: string,
616
+ redactNames: boolean,
617
+ ): string {
618
+ return redactDemoStringInternal(text, salt, redactNames);
619
+ }
620
+
621
+ function redactNumberLeaf(value: number, salt: string): number {
622
+ if (!Number.isFinite(value)) return value;
623
+ const repr = String(value);
624
+ // Re-use the string number transform but only if the representation is a
625
+ // clean numeric token we can round-trip back to a JS number.
626
+ const redacted = transformNumbers(repr, salt);
627
+ if (redacted === repr) return value;
628
+ const n = Number(redacted);
629
+ if (!Number.isFinite(n)) return value;
630
+ return n;
631
+ }
632
+
633
+ function walk(
634
+ value: unknown,
635
+ salt: string,
636
+ depth: number,
637
+ seen: WeakSet<object>,
638
+ redactNames: boolean,
639
+ ): unknown {
640
+ if (depth > MAX_DEPTH) return value;
641
+
642
+ if (value === null || value === undefined) return value;
643
+
644
+ const t = typeof value;
645
+
646
+ if (t === "string") {
647
+ return redactDemoStringWithSalt(value as string, salt, redactNames);
648
+ }
649
+
650
+ if (t === "number") {
651
+ return redactNumberLeaf(value as number, salt);
652
+ }
653
+
654
+ if (t === "boolean" || t === "bigint" || t === "function" || t === "symbol") {
655
+ return value;
656
+ }
657
+
658
+ if (value instanceof Date) return value;
659
+
660
+ if (Array.isArray(value)) {
661
+ if (seen.has(value)) return value;
662
+ seen.add(value);
663
+ const out = value.map((item) =>
664
+ walk(item, salt, depth + 1, seen, redactNames),
665
+ );
666
+ seen.delete(value);
667
+ return out;
668
+ }
669
+
670
+ if (isPlainObject(value)) {
671
+ if (seen.has(value)) return value;
672
+ seen.add(value);
673
+ const out: Record<string, unknown> = {};
674
+ for (const [key, entry] of Object.entries(value)) {
675
+ const keyIsProtected = PROTECTED_KEY_RE.test(key);
676
+ if (keyIsProtected) {
677
+ if (
678
+ entry !== null &&
679
+ typeof entry === "object" &&
680
+ !(entry instanceof Date)
681
+ ) {
682
+ // Still recurse into nested structures, but the protected key does
683
+ // not transform its own leaf value.
684
+ out[key] = walk(entry, salt, depth + 1, seen, redactNames);
685
+ } else {
686
+ // Leaf under a protected key: pass through completely untouched.
687
+ out[key] = entry;
688
+ }
689
+ continue;
690
+ }
691
+ out[key] = walk(
692
+ entry,
693
+ salt,
694
+ depth + 1,
695
+ seen,
696
+ redactNames && shouldRedactNamesForChild(key, entry),
697
+ );
698
+ }
699
+ seen.delete(value);
700
+ return out;
701
+ }
702
+
703
+ // Unknown object kind (Map, Set, class instance, etc.) — leave untouched.
704
+ return value;
705
+ }
706
+
707
+ export function redactDemoData<T>(value: T, opts?: RedactOptions): T {
708
+ const salt = opts?.salt ?? "";
709
+ return walk(value, salt, 0, new WeakSet<object>(), true) as T;
710
+ }
711
+
712
+ /**
713
+ * Clear the stable-mapping caches. Test-only — the caches are process-global
714
+ * (intentionally, so mappings stay stable for a tab's session), which would
715
+ * otherwise let one test's produced fakes leak into another's assertions.
716
+ */
717
+ export function __resetDemoRedactCacheForTests(): void {
718
+ forwardCache.clear();
719
+ producedFakes.clear();
720
+ }