@agent-native/core 0.120.3 → 0.121.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.
Files changed (71) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +22 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/use-db-sync.ts +23 -21
  5. package/corpus/core/src/deploy/build.ts +1 -0
  6. package/corpus/core/src/server/core-routes-plugin.ts +3 -0
  7. package/corpus/core/src/server/gateway-access-check.ts +67 -0
  8. package/corpus/core/src/server/poll.ts +295 -9
  9. package/corpus/core/src/server/short-lived-token.ts +122 -0
  10. package/corpus/templates/content/actions/update-document.ts +145 -91
  11. package/corpus/templates/content/app/components/editor/CommentComposer.tsx +4 -1
  12. package/corpus/templates/content/app/components/editor/CommentsSidebar.tsx +366 -164
  13. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +51 -28
  14. package/corpus/templates/content/app/components/editor/database/sidebar.tsx +19 -8
  15. package/corpus/templates/content/app/i18n-data.ts +15 -0
  16. package/corpus/templates/content/changelog/2026-07-23-shared-pages-now-keep-durable-content-visible-and-comment-th.md +6 -0
  17. package/corpus/templates/content/changelog/2026-07-24-comment-drafts-now-stay-open-when-saving-fails-and-long-live.md +6 -0
  18. package/corpus/templates/design/actions/update-file.ts +85 -18
  19. package/corpus/templates/design/app/lib/design-save-outbox.ts +39 -0
  20. package/corpus/templates/slides/app/components/editor/EditorToolbar.tsx +29 -4
  21. package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +365 -9
  22. package/corpus/templates/slides/app/components/editor/bullet-editing.ts +350 -0
  23. package/corpus/templates/slides/app/context/DeckContext.tsx +1 -3
  24. package/corpus/templates/slides/app/i18n/en-US.ts +2 -0
  25. package/corpus/templates/slides/app/pages/DeckEditor.tsx +5 -0
  26. package/corpus/templates/slides/changelog/2026-07-23-new-bullet-rows-created-with-enter-now-keep-the-list-item-s-.md +6 -0
  27. package/corpus/templates/slides/changelog/2026-07-23-pressing-enter-in-generated-checkbox-shape-marker-lists-now-.md +6 -0
  28. package/corpus/templates/slides/changelog/2026-07-24-typing-a-markdown-style-dash-space-at-the-start-of-a-text-bl.md +6 -0
  29. package/corpus/toolkit/CHANGELOG.md +6 -0
  30. package/corpus/toolkit/package.json +1 -1
  31. package/corpus/toolkit/src/editor/useCollabReconcile.ts +83 -15
  32. package/dist/client/use-db-sync.d.ts.map +1 -1
  33. package/dist/client/use-db-sync.js +27 -22
  34. package/dist/client/use-db-sync.js.map +1 -1
  35. package/dist/collab/awareness.d.ts +2 -2
  36. package/dist/collab/awareness.d.ts.map +1 -1
  37. package/dist/deploy/build.d.ts.map +1 -1
  38. package/dist/deploy/build.js +1 -0
  39. package/dist/deploy/build.js.map +1 -1
  40. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  41. package/dist/notifications/routes.d.ts +1 -1
  42. package/dist/observability/routes.d.ts +3 -3
  43. package/dist/progress/routes.d.ts +1 -1
  44. package/dist/provider-api/actions/custom-provider-registration.d.ts +4 -4
  45. package/dist/provider-api/actions/provider-api.d.ts +6 -6
  46. package/dist/resources/handlers.d.ts +1 -1
  47. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  48. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  49. package/dist/server/core-routes-plugin.js +3 -0
  50. package/dist/server/core-routes-plugin.js.map +1 -1
  51. package/dist/server/gateway-access-check.d.ts +12 -0
  52. package/dist/server/gateway-access-check.d.ts.map +1 -0
  53. package/dist/server/gateway-access-check.js +49 -0
  54. package/dist/server/gateway-access-check.js.map +1 -0
  55. package/dist/server/poll.d.ts +55 -1
  56. package/dist/server/poll.d.ts.map +1 -1
  57. package/dist/server/poll.js +254 -7
  58. package/dist/server/poll.js.map +1 -1
  59. package/dist/server/realtime-token.d.ts +1 -1
  60. package/dist/server/short-lived-token.d.ts +28 -0
  61. package/dist/server/short-lived-token.d.ts.map +1 -1
  62. package/dist/server/short-lived-token.js +78 -0
  63. package/dist/server/short-lived-token.js.map +1 -1
  64. package/dist/server/transcribe-voice.d.ts +1 -1
  65. package/package.json +2 -2
  66. package/src/client/use-db-sync.ts +23 -21
  67. package/src/deploy/build.ts +1 -0
  68. package/src/server/core-routes-plugin.ts +3 -0
  69. package/src/server/gateway-access-check.ts +67 -0
  70. package/src/server/poll.ts +295 -9
  71. package/src/server/short-lived-token.ts +122 -0
@@ -324,3 +324,125 @@ export function verifyRealtimeSubscribeToken(
324
324
  exp: claims.exp,
325
325
  };
326
326
  }
327
+
328
+ // ── Gateway access-check tokens ──────────────────────────────────────────────
329
+ //
330
+ // The hosted gateway has no access to an app's shareable-resource registry, so
331
+ // it cannot resolve sharee visibility itself. It signs one of these with the
332
+ // app's per-project key and calls the app's `/_agent-native/can-see`, which runs
333
+ // `resolveAccess` and answers. The full access query is bound into the token so
334
+ // the app authenticates the params, not merely the caller.
335
+
336
+ /** Payload `typ` discriminator for gateway access-check tokens. */
337
+ export const GATEWAY_ACCESS_TOKEN_TYPE = "rt-access-check";
338
+ /** Short TTL: minted per check, used immediately server-to-server. */
339
+ const DEFAULT_GATEWAY_ACCESS_TTL_SECONDS = 60;
340
+
341
+ /** Inputs for {@link signGatewayAccessToken}. */
342
+ export interface GatewayAccessClaims {
343
+ projectId: string;
344
+ resourceType: string;
345
+ resourceId: string;
346
+ /** App end-user whose visibility of the resource is being checked. */
347
+ userEmail: string;
348
+ orgId?: string;
349
+ ttlSeconds?: number;
350
+ }
351
+
352
+ interface DecodedGatewayAccessClaims {
353
+ typ: string;
354
+ projectId: string;
355
+ resourceType: string;
356
+ resourceId: string;
357
+ userEmail: string;
358
+ orgId?: string;
359
+ exp: number;
360
+ }
361
+
362
+ /** Result of {@link verifyGatewayAccessToken}; on success carries the bound query. */
363
+ export type GatewayAccessVerifyResult =
364
+ | {
365
+ ok: true;
366
+ projectId: string;
367
+ resourceType: string;
368
+ resourceId: string;
369
+ userEmail: string;
370
+ orgId?: string;
371
+ }
372
+ | { ok: false; reason: string };
373
+
374
+ /** Mint a gateway access-check token, signed with the app's per-project `key`. */
375
+ export function signGatewayAccessToken(
376
+ claims: GatewayAccessClaims,
377
+ key: string,
378
+ ): string {
379
+ if (!key) throw new Error("signGatewayAccessToken requires a key");
380
+ const ttl = claims.ttlSeconds ?? DEFAULT_GATEWAY_ACCESS_TTL_SECONDS;
381
+ const payload: DecodedGatewayAccessClaims = {
382
+ typ: GATEWAY_ACCESS_TOKEN_TYPE,
383
+ projectId: claims.projectId,
384
+ resourceType: claims.resourceType,
385
+ resourceId: claims.resourceId,
386
+ userEmail: claims.userEmail,
387
+ exp: Math.floor(Date.now() / 1000) + ttl,
388
+ };
389
+ if (claims.orgId) payload.orgId = claims.orgId;
390
+ const payloadStr = base64UrlEncode(JSON.stringify(payload));
391
+ return `${payloadStr}.${hmacB64(payloadStr, key)}`;
392
+ }
393
+
394
+ /** Verify a gateway access-check token against the app's per-project `key`. */
395
+ export function verifyGatewayAccessToken(
396
+ token: string,
397
+ key: string,
398
+ expectedProjectId?: string,
399
+ ): GatewayAccessVerifyResult {
400
+ if (!key) return { ok: false, reason: "no_key" };
401
+ if (typeof token !== "string" || !token.includes(".")) {
402
+ return { ok: false, reason: "malformed" };
403
+ }
404
+ const [payloadStr, sig] = token.split(".", 2);
405
+ if (!payloadStr || !sig) return { ok: false, reason: "malformed" };
406
+
407
+ if (!timingSafeEqualB64(sig, hmacB64(payloadStr, key))) {
408
+ return { ok: false, reason: "bad_signature" };
409
+ }
410
+
411
+ let claims: DecodedGatewayAccessClaims;
412
+ try {
413
+ claims = JSON.parse(base64UrlDecode(payloadStr).toString("utf8"));
414
+ } catch {
415
+ return { ok: false, reason: "bad_payload" };
416
+ }
417
+
418
+ if (claims.typ !== GATEWAY_ACCESS_TOKEN_TYPE) {
419
+ return { ok: false, reason: "wrong_type" };
420
+ }
421
+ if (typeof claims.exp !== "number")
422
+ return { ok: false, reason: "bad_payload" };
423
+ if (claims.exp * 1000 < Date.now()) return { ok: false, reason: "expired" };
424
+ if (
425
+ !claims.projectId ||
426
+ !claims.resourceType ||
427
+ !claims.resourceId ||
428
+ !claims.userEmail
429
+ ) {
430
+ return { ok: false, reason: "bad_payload" };
431
+ }
432
+ // Optional channel binding, mirroring verifyRealtimeSubscribeToken. The
433
+ // per-project key already scopes verification to one app; this is belt-and-
434
+ // suspenders for a future multi-tenant secret store. Skipped when the caller
435
+ // can't cheaply resolve its own project id (scoped-secret apps).
436
+ if (expectedProjectId && claims.projectId !== expectedProjectId) {
437
+ return { ok: false, reason: "wrong_project" };
438
+ }
439
+
440
+ return {
441
+ ok: true,
442
+ projectId: claims.projectId,
443
+ resourceType: claims.resourceType,
444
+ resourceId: claims.resourceId,
445
+ userEmail: claims.userEmail,
446
+ orgId: claims.orgId,
447
+ };
448
+ }
@@ -40,6 +40,33 @@ export interface DocumentUpdateConflictResponse {
40
40
  document: DocumentUpdateResponse;
41
41
  }
42
42
 
43
+ const documentAuditOwner = Symbol("documentAuditOwner");
44
+
45
+ type DocumentAuditScopedResult = {
46
+ [documentAuditOwner]?: string;
47
+ };
48
+
49
+ function scopeDocumentAudit<T extends object>(result: T, ownerEmail: string) {
50
+ Object.defineProperty(result, documentAuditOwner, { value: ownerEmail });
51
+ return result;
52
+ }
53
+
54
+ function isFavoriteOnlyUpdate(args: {
55
+ isFavorite?: boolean;
56
+ title?: string;
57
+ content?: string;
58
+ description?: string;
59
+ icon?: string | null;
60
+ }) {
61
+ return (
62
+ args.isFavorite !== undefined &&
63
+ args.title === undefined &&
64
+ args.content === undefined &&
65
+ args.description === undefined &&
66
+ args.icon === undefined
67
+ );
68
+ }
69
+
43
70
  function nanoid(size = 12): string {
44
71
  const chars =
45
72
  "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
@@ -301,6 +328,29 @@ export default defineAction({
301
328
  .default([])
302
329
  .describe("Exact item versions that influenced this agent update."),
303
330
  }),
331
+ audit: {
332
+ // Document bodies and personal favorite preferences are both sensitive.
333
+ // Keep actor/target/outcome attribution without copying mutation payloads
334
+ // into an owner-visible audit row.
335
+ recordInputs: false,
336
+ target: (args, result) => {
337
+ const favoriteOnly = isFavoriteOnlyUpdate(args);
338
+ return {
339
+ type: "document",
340
+ id: args.id,
341
+ // Favorites are a private preference owned by the actor, even when
342
+ // the underlying document belongs to somebody else.
343
+ ownerEmail: favoriteOnly
344
+ ? undefined
345
+ : (result as DocumentAuditScopedResult | null)?.[documentAuditOwner],
346
+ visibility: "private",
347
+ };
348
+ },
349
+ summary: (args, result) =>
350
+ (result as DocumentUpdateConflictResponse | null)?.conflict
351
+ ? `Document update conflicted for ${args.id}`
352
+ : `Updated document ${args.id}`,
353
+ },
304
354
  run: async (
305
355
  args,
306
356
  ctx,
@@ -315,12 +365,7 @@ export default defineAction({
315
365
  const isAgentCaller =
316
366
  ctx?.caller === "tool" || ctx?.caller === "mcp" || ctx?.caller === "a2a";
317
367
 
318
- const favoriteOnly =
319
- args.isFavorite !== undefined &&
320
- args.title === undefined &&
321
- args.content === undefined &&
322
- args.description === undefined &&
323
- args.icon === undefined;
368
+ const favoriteOnly = isFavoriteOnlyUpdate(args);
324
369
  const access = favoriteOnly
325
370
  ? await resolveContentDocumentAccess(id)
326
371
  : await assertAccess("document", id, "editor");
@@ -411,6 +456,11 @@ export default defineAction({
411
456
  args.title !== undefined && args.title !== existing.title;
412
457
  const contentChanged =
413
458
  content !== undefined && content !== existing.content;
459
+ const clearsNonEmptyContent =
460
+ contentChanged &&
461
+ content !== undefined &&
462
+ isEffectivelyEmptyDocumentContent(content) &&
463
+ !isEffectivelyEmptyDocumentContent(existing.content);
414
464
  const iconChanged = args.icon !== undefined && args.icon !== existing.icon;
415
465
  const favoriteChanged =
416
466
  args.isFavorite !== undefined && args.isFavorite !== currentFavorite;
@@ -426,40 +476,6 @@ export default defineAction({
426
476
  favoriteChanged ||
427
477
  descriptionChanged;
428
478
 
429
- // Snapshot the current state before applying content/title changes.
430
- // Versions are scoped to the document owner, not the caller — an editor
431
- // share collaborator shouldn't create a phantom version row under their
432
- // own email.
433
- if (titleChanged || contentChanged) {
434
- const [latestVersion] = await db
435
- .select({ createdAt: schema.documentVersions.createdAt })
436
- .from(schema.documentVersions)
437
- .where(
438
- and(
439
- eq(schema.documentVersions.documentId, id),
440
- eq(schema.documentVersions.ownerEmail, ownerEmail),
441
- ),
442
- )
443
- .orderBy(desc(schema.documentVersions.createdAt))
444
- .limit(1);
445
-
446
- const shouldSnapshot =
447
- !latestVersion ||
448
- Date.now() - new Date(latestVersion.createdAt).getTime() >
449
- SNAPSHOT_INTERVAL_MS;
450
-
451
- if (shouldSnapshot) {
452
- await db.insert(schema.documentVersions).values({
453
- id: nanoid(),
454
- ownerEmail,
455
- documentId: id,
456
- title: existing.title,
457
- content: existing.content,
458
- createdAt: new Date().toISOString(),
459
- });
460
- }
461
- }
462
-
463
479
  let softDeletedDatabaseIds: string[] = [];
464
480
  let creativeContext:
465
481
  | Awaited<ReturnType<typeof documentMutationCreativeContext>>
@@ -507,6 +523,36 @@ export default defineAction({
507
523
  .where(eq(schema.documents.id, id));
508
524
  }
509
525
 
526
+ if (titleChanged || contentChanged) {
527
+ const [latestVersion] = await tx
528
+ .select({ createdAt: schema.documentVersions.createdAt })
529
+ .from(schema.documentVersions)
530
+ .where(
531
+ and(
532
+ eq(schema.documentVersions.documentId, id),
533
+ eq(schema.documentVersions.ownerEmail, ownerEmail),
534
+ ),
535
+ )
536
+ .orderBy(desc(schema.documentVersions.createdAt))
537
+ .limit(1);
538
+ const shouldSnapshot =
539
+ clearsNonEmptyContent ||
540
+ !latestVersion ||
541
+ Date.now() - new Date(latestVersion.createdAt).getTime() >
542
+ SNAPSHOT_INTERVAL_MS;
543
+
544
+ if (shouldSnapshot) {
545
+ await tx.insert(schema.documentVersions).values({
546
+ id: nanoid(),
547
+ ownerEmail,
548
+ documentId: id,
549
+ title: existing.title,
550
+ content: existing.content,
551
+ createdAt: new Date().toISOString(),
552
+ });
553
+ }
554
+ }
555
+
510
556
  if (favoriteChanged) {
511
557
  await setFavoriteMembership({
512
558
  db: tx,
@@ -580,30 +626,35 @@ export default defineAction({
580
626
  .select()
581
627
  .from(schema.documents)
582
628
  .where(eq(schema.documents.id, id));
583
- return {
584
- conflict: true,
585
- id,
586
- document: {
587
- id: current.id,
588
- urlPath: `/page/${current.id}`,
589
- parentId: current.parentId,
590
- title: current.title,
591
- content: current.content,
592
- description: current.description,
593
- icon: current.icon,
594
- position: current.position,
595
- isFavorite: currentFavorite,
596
- hideFromSearch: parseDocumentHideFromSearch(current.hideFromSearch),
597
- visibility: current.visibility,
598
- accessRole: access.role,
599
- canEdit: canEditRole(access.role),
600
- canManage: canManageRole(access.role),
601
- createdAt: current.createdAt,
602
- updatedAt: current.updatedAt,
603
- source: serializeDocumentSource(current),
604
- softDeletedDatabaseIds: [],
605
- },
606
- };
629
+ return scopeDocumentAudit(
630
+ {
631
+ conflict: true,
632
+ id,
633
+ document: {
634
+ id: current.id,
635
+ urlPath: `/page/${current.id}`,
636
+ parentId: current.parentId,
637
+ title: current.title,
638
+ content: current.content,
639
+ description: current.description,
640
+ icon: current.icon,
641
+ position: current.position,
642
+ isFavorite: currentFavorite,
643
+ hideFromSearch: parseDocumentHideFromSearch(
644
+ current.hideFromSearch,
645
+ ),
646
+ visibility: current.visibility,
647
+ accessRole: access.role,
648
+ canEdit: canEditRole(access.role),
649
+ canManage: canManageRole(access.role),
650
+ createdAt: current.createdAt,
651
+ updatedAt: current.updatedAt,
652
+ source: serializeDocumentSource(current),
653
+ softDeletedDatabaseIds: [],
654
+ },
655
+ } satisfies DocumentUpdateConflictResponse,
656
+ ownerEmail,
657
+ );
607
658
  }
608
659
 
609
660
  if (contentChanged) {
@@ -665,32 +716,35 @@ export default defineAction({
665
716
 
666
717
  await writeAppState("refresh-signal", { ts: Date.now() });
667
718
 
668
- return {
669
- id: doc.id,
670
- urlPath: `/page/${doc.id}`,
671
- parentId: doc.parentId,
672
- title: doc.title,
673
- content: doc.content,
674
- description: doc.description,
675
- icon: doc.icon,
676
- position: doc.position,
677
- isFavorite: finalFavorite,
678
- hideFromSearch: parseDocumentHideFromSearch(doc.hideFromSearch),
679
- visibility: doc.visibility,
680
- accessRole: access.role,
681
- canEdit: canEditRole(access.role),
682
- canManage: canManageRole(access.role),
683
- createdAt: doc.createdAt,
684
- updatedAt: doc.updatedAt,
685
- source: serializeDocumentSource(doc),
686
- softDeletedDatabaseIds,
687
- ...(creativeContext
688
- ? {
689
- contextMode: creativeContext.contextMode,
690
- contextPackId: creativeContext.contextPackId,
691
- reuseLabels: creativeContext.reuseLabels,
692
- }
693
- : {}),
694
- };
719
+ return scopeDocumentAudit(
720
+ {
721
+ id: doc.id,
722
+ urlPath: `/page/${doc.id}`,
723
+ parentId: doc.parentId,
724
+ title: doc.title,
725
+ content: doc.content,
726
+ description: doc.description,
727
+ icon: doc.icon,
728
+ position: doc.position,
729
+ isFavorite: finalFavorite,
730
+ hideFromSearch: parseDocumentHideFromSearch(doc.hideFromSearch),
731
+ visibility: doc.visibility,
732
+ accessRole: access.role,
733
+ canEdit: canEditRole(access.role),
734
+ canManage: canManageRole(access.role),
735
+ createdAt: doc.createdAt,
736
+ updatedAt: doc.updatedAt,
737
+ source: serializeDocumentSource(doc),
738
+ softDeletedDatabaseIds,
739
+ ...(creativeContext
740
+ ? {
741
+ contextMode: creativeContext.contextMode,
742
+ contextPackId: creativeContext.contextPackId,
743
+ reuseLabels: creativeContext.reuseLabels,
744
+ }
745
+ : {}),
746
+ } satisfies DocumentUpdateResponse,
747
+ ownerEmail,
748
+ );
695
749
  },
696
750
  });
@@ -28,6 +28,7 @@ interface CommentComposerProps {
28
28
  members: MentionMember[];
29
29
  placeholder?: string;
30
30
  autoFocus?: boolean;
31
+ disabled?: boolean;
31
32
  rows?: number;
32
33
  className?: string;
33
34
  }
@@ -52,6 +53,7 @@ export const CommentComposer = forwardRef<
52
53
  members,
53
54
  placeholder,
54
55
  autoFocus,
56
+ disabled = false,
55
57
  rows = 2,
56
58
  className,
57
59
  },
@@ -158,6 +160,7 @@ export const CommentComposer = forwardRef<
158
160
  <textarea
159
161
  ref={setRefs}
160
162
  value={value}
163
+ disabled={disabled}
161
164
  rows={rows}
162
165
  onChange={(e) => {
163
166
  onChange(e.target.value);
@@ -177,7 +180,7 @@ export const CommentComposer = forwardRef<
177
180
  "w-full resize-none bg-transparent text-sm placeholder:text-muted-foreground focus:outline-none"
178
181
  }
179
182
  />
180
- {menuOpen && (
183
+ {!disabled && menuOpen && (
181
184
  <div className="absolute left-0 right-0 top-full z-20 mt-1 overflow-hidden rounded-md border border-border bg-popover py-1 shadow-md">
182
185
  {filtered.map((member, i) => (
183
186
  <button