@agent-native/core 0.120.4 → 0.121.1

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 (66) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +20 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/agent/durable-background.ts +12 -4
  5. package/corpus/core/src/agent/run-manager.ts +3 -0
  6. package/corpus/core/src/client/use-db-sync.ts +23 -21
  7. package/corpus/core/src/deploy/build.ts +1 -0
  8. package/corpus/core/src/server/core-routes-plugin.ts +3 -0
  9. package/corpus/core/src/server/gateway-access-check.ts +67 -0
  10. package/corpus/core/src/server/poll.ts +295 -9
  11. package/corpus/core/src/server/short-lived-token.ts +122 -0
  12. package/corpus/templates/slides/app/components/editor/EditorToolbar.tsx +29 -4
  13. package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +365 -9
  14. package/corpus/templates/slides/app/components/editor/bullet-editing.ts +350 -0
  15. package/corpus/templates/slides/app/context/DeckContext.tsx +1 -3
  16. package/corpus/templates/slides/app/i18n/en-US.ts +2 -0
  17. package/corpus/templates/slides/app/pages/DeckEditor.tsx +5 -0
  18. package/corpus/templates/slides/changelog/2026-07-23-new-bullet-rows-created-with-enter-now-keep-the-list-item-s-.md +6 -0
  19. package/corpus/templates/slides/changelog/2026-07-23-pressing-enter-in-generated-checkbox-shape-marker-lists-now-.md +6 -0
  20. package/corpus/templates/slides/changelog/2026-07-24-typing-a-markdown-style-dash-space-at-the-start-of-a-text-bl.md +6 -0
  21. package/dist/agent/durable-background.d.ts.map +1 -1
  22. package/dist/agent/durable-background.js +16 -4
  23. package/dist/agent/durable-background.js.map +1 -1
  24. package/dist/agent/run-manager.d.ts.map +1 -1
  25. package/dist/agent/run-manager.js +6 -0
  26. package/dist/agent/run-manager.js.map +1 -1
  27. package/dist/client/use-db-sync.d.ts.map +1 -1
  28. package/dist/client/use-db-sync.js +27 -22
  29. package/dist/client/use-db-sync.js.map +1 -1
  30. package/dist/collab/awareness.d.ts +2 -2
  31. package/dist/collab/awareness.d.ts.map +1 -1
  32. package/dist/deploy/build.d.ts.map +1 -1
  33. package/dist/deploy/build.js +1 -0
  34. package/dist/deploy/build.js.map +1 -1
  35. package/dist/observability/routes.d.ts +3 -3
  36. package/dist/progress/routes.d.ts +1 -1
  37. package/dist/provider-api/actions/custom-provider-registration.d.ts +4 -4
  38. package/dist/provider-api/actions/provider-api.d.ts +6 -6
  39. package/dist/resources/handlers.d.ts +1 -1
  40. package/dist/secrets/routes.d.ts +9 -9
  41. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  42. package/dist/server/core-routes-plugin.js +3 -0
  43. package/dist/server/core-routes-plugin.js.map +1 -1
  44. package/dist/server/gateway-access-check.d.ts +12 -0
  45. package/dist/server/gateway-access-check.d.ts.map +1 -0
  46. package/dist/server/gateway-access-check.js +49 -0
  47. package/dist/server/gateway-access-check.js.map +1 -0
  48. package/dist/server/poll.d.ts +55 -1
  49. package/dist/server/poll.d.ts.map +1 -1
  50. package/dist/server/poll.js +254 -7
  51. package/dist/server/poll.js.map +1 -1
  52. package/dist/server/realtime-token.d.ts +1 -1
  53. package/dist/server/short-lived-token.d.ts +28 -0
  54. package/dist/server/short-lived-token.d.ts.map +1 -1
  55. package/dist/server/short-lived-token.js +78 -0
  56. package/dist/server/short-lived-token.js.map +1 -1
  57. package/dist/server/transcribe-voice.d.ts +1 -1
  58. package/package.json +1 -1
  59. package/src/agent/durable-background.ts +12 -4
  60. package/src/agent/run-manager.ts +3 -0
  61. package/src/client/use-db-sync.ts +23 -21
  62. package/src/deploy/build.ts +1 -0
  63. package/src/server/core-routes-plugin.ts +3 -0
  64. package/src/server/gateway-access-check.ts +67 -0
  65. package/src/server/poll.ts +295 -9
  66. package/src/server/short-lived-token.ts +122 -0
@@ -93,6 +93,42 @@ const ACCESS_CACHE_DENY_TTL_MS = 5_000;
93
93
  const ACCESS_CACHE_MAX = 500;
94
94
  const SCREEN_REFRESH_KEY = "__screen_refresh__";
95
95
 
96
+ /**
97
+ * DB-assigned version allocation (see `AppSyncStateOptions.dbAssignedVersions`).
98
+ * The allocator is a one-row table advanced with `GREATEST(v + 1, epoch_ms)` —
99
+ * the SQL analog of the in-memory `Math.max(version + 1, Date.now())` — inside
100
+ * the same autocommit statement as the event insert. The row lock is held to
101
+ * the statement's implicit commit, so version order equals commit order across
102
+ * every writer, closing the cross-writer clock-skew hole no sequence closes
103
+ * (sequences serialize allocation, not commit visibility). Single-statement is
104
+ * required: the gateway reaches the app DB through a pooled (transactionless)
105
+ * connection. `ON CONFLICT DO UPDATE` (not DO NOTHING) so a deterministic-id
106
+ * dedupe loser still gets the winner's version back and never emits a phantom
107
+ * version to its own clients. The stored JSON is restamped with the allocated
108
+ * version; the read path's column-wins override makes that belt-and-suspenders.
109
+ */
110
+ const SEED_SYNC_VERSION_SQL = `
111
+ INSERT INTO sync_version (id, v)
112
+ SELECT 1, GREATEST(
113
+ COALESCE((SELECT MAX(version) FROM sync_events), 0),
114
+ (EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::BIGINT
115
+ )
116
+ ON CONFLICT (id) DO NOTHING
117
+ `;
118
+ const ALLOCATING_INSERT_SQL = `
119
+ WITH alloc AS (
120
+ UPDATE sync_version
121
+ SET v = GREATEST(v + 1, (EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::BIGINT, (?)::BIGINT)
122
+ WHERE id = 1
123
+ RETURNING v
124
+ )
125
+ INSERT INTO sync_events (id, version, event_json, source, type, event_key, owner, org_id, resource_type, resource_id, created_at)
126
+ SELECT ?, alloc.v, jsonb_set((?)::jsonb, '{version}', to_jsonb(alloc.v))::text, ?, ?, ?, ?, ?, ?, ?, ?
127
+ FROM alloc
128
+ ON CONFLICT (id) DO UPDATE SET id = excluded.id
129
+ RETURNING version
130
+ `;
131
+
96
132
  function timestampValue(value: unknown): number {
97
133
  if (typeof value === "number" && Number.isFinite(value)) return value;
98
134
  if (typeof value !== "string") return 0;
@@ -341,6 +377,22 @@ export interface AppSyncStateOptions {
341
377
  * across instances.
342
378
  */
343
379
  deterministicEventIds?: boolean;
380
+ /**
381
+ * Allocate durable-event versions from the app's Postgres DB (a one-row
382
+ * allocator advanced with `GREATEST(v + 1, epoch_ms_now)` inside the insert
383
+ * statement) instead of the per-writer in-memory clock counter.
384
+ *
385
+ * Off by default: a single-writer app's clock counter is already monotonic.
386
+ * Hosted realtime has MULTIPLE writers per app DB (the app's serverless
387
+ * instances plus gateway instances), where clock skew can assign a LOWER
388
+ * version to a LATER event — a client whose cursor passed the higher value
389
+ * then filters the later event out forever. DB allocation serializes on the
390
+ * allocator row's lock, so version order equals commit order across all
391
+ * writers. Versions stay on the epoch-ms scale (existing cursors, the
392
+ * detector's timestamp-mixed seed, and lag metrics all assume it).
393
+ * Postgres only; ignored on SQLite.
394
+ */
395
+ dbAssignedVersions?: boolean;
344
396
  }
345
397
 
346
398
  /**
@@ -354,6 +406,13 @@ export class AppSyncState {
354
406
  private readonly isPg: () => boolean;
355
407
  private readonly resolveAccessFn: AccessResolver;
356
408
  private readonly deterministicEventIds: boolean;
409
+ private readonly dbAssignedVersions: boolean;
410
+ /** Serializes gated recordChange calls so buffer/emit order matches
411
+ * allocation order (interleaved allocations could buffer out of order). */
412
+ private recordChain: Promise<void> = Promise.resolve();
413
+ /** Count of DB-allocation failures that fell back to clock versions. */
414
+ private dbVersionFallbacks = 0;
415
+ private warnedListenerThrow = false;
357
416
 
358
417
  // Timestamp-aligned versions so all serverless instances produce values in
359
418
  // the same range (seeded from DB, then incremented via Date.now). Plain
@@ -415,6 +474,7 @@ export class AppSyncState {
415
474
  this.isPg = options.isPostgres ?? isPostgres;
416
475
  this.resolveAccessFn = options.resolveAccess ?? defaultResolveAccess;
417
476
  this.deterministicEventIds = options.deterministicEventIds ?? false;
477
+ this.dbAssignedVersions = options.dbAssignedVersions ?? false;
418
478
  this.pollEmitter.setMaxListeners(0);
419
479
  }
420
480
 
@@ -522,6 +582,16 @@ export class AppSyncState {
522
582
  "CREATE INDEX IF NOT EXISTS sync_events_org_version_idx ON sync_events (org_id, version)",
523
583
  guardOptions,
524
584
  );
585
+ if (this.dbAssignedVersions) {
586
+ await ensureTableExists(
587
+ "sync_version",
588
+ "CREATE TABLE IF NOT EXISTS sync_version (id INT PRIMARY KEY, v BIGINT NOT NULL)",
589
+ guardOptions,
590
+ );
591
+ // Seed at/above both the existing durable max and wall clock, so
592
+ // allocated versions never land below live client cursors.
593
+ await client.execute(SEED_SYNC_VERSION_SQL);
594
+ }
525
595
  return true;
526
596
  }
527
597
 
@@ -561,6 +631,7 @@ export class AppSyncState {
561
631
  async persistSyncEvent(
562
632
  event: ChangeEvent,
563
633
  dedupeKey?: string,
634
+ presetId?: string,
564
635
  ): Promise<void> {
565
636
  if (!(await this.ensureSyncEventsTable())) return;
566
637
  const client = this.getDb();
@@ -573,7 +644,9 @@ export class AppSyncState {
573
644
  : `INSERT OR IGNORE INTO sync_events (id, version, event_json, source, type, event_key, owner, org_id, resource_type, resource_id, created_at)
574
645
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
575
646
  args: [
576
- this.durableEventId(event, dedupeKey),
647
+ // presetId lets a gated fallback reuse the allocating attempt's id,
648
+ // so a commit-then-timeout can't produce the same event twice.
649
+ presetId ?? this.durableEventId(event, dedupeKey),
577
650
  event.version,
578
651
  JSON.stringify(event),
579
652
  event.source,
@@ -590,6 +663,84 @@ export class AppSyncState {
590
663
  await this.pruneDurableEvents(client);
591
664
  }
592
665
 
666
+ /**
667
+ * Persist with a DB-allocated version (see ALLOCATING_INSERT_SQL). Returns
668
+ * the allocated version — for a deterministic-id dedupe loser, the winner's
669
+ * existing version — or null when persistence is unavailable (caller falls
670
+ * back to clock allocation). Unlike `persistSyncEvent`, errors PROPAGATE to
671
+ * the caller: silence here would silently drop the event entirely, since the
672
+ * buffer/emit are deferred behind this call.
673
+ */
674
+ private async persistWithDbAssignedVersion(
675
+ event: { source: string; type: string; key?: string; [k: string]: unknown },
676
+ id: string,
677
+ ): Promise<number | null> {
678
+ if (!(await this.ensureSyncEventsTable())) return null;
679
+ const client = this.getDb();
680
+ const args = [
681
+ // Local monotonicity floor: after a clock fallback, the next allocation
682
+ // must not land at/below versions this writer already emitted.
683
+ this.version + 1,
684
+ id,
685
+ // jsonb (unlike the legacy TEXT write) rejects \\u0000 escapes; strip
686
+ // them rather than diverting those events to the clock fallback.
687
+ JSON.stringify(event).split("\\u0000").join(""),
688
+ event.source,
689
+ event.type,
690
+ event.key ?? null,
691
+ (event.owner as string | undefined) ?? null,
692
+ (event.orgId as string | undefined) ?? null,
693
+ (event.resourceType as string | undefined) ?? null,
694
+ (event.resourceId as string | undefined) ?? null,
695
+ Date.now(),
696
+ ];
697
+ let result = await client.execute({ sql: ALLOCATING_INSERT_SQL, args });
698
+ if (result.rows.length === 0) {
699
+ // Allocator row missing (e.g. wiped after ensure): reseed, retry once.
700
+ await client.execute(SEED_SYNC_VERSION_SQL).catch(() => {});
701
+ result = await client.execute({ sql: ALLOCATING_INSERT_SQL, args });
702
+ }
703
+ const version = timestampValue(result.rows[0]?.version);
704
+ await this.pruneDurableEvents(client);
705
+ return version > 0 ? version : null;
706
+ }
707
+
708
+ /** Read back the version of an already-committed row (a failed allocating
709
+ * call whose statement actually committed). Null when absent/unreachable. */
710
+ private async recoverCommittedVersion(id: string): Promise<number | null> {
711
+ try {
712
+ const result = await this.getDb().execute({
713
+ sql: "SELECT version FROM sync_events WHERE id = ?",
714
+ args: [id],
715
+ });
716
+ const version = timestampValue(result.rows[0]?.version);
717
+ return version > 0 ? version : null;
718
+ } catch {
719
+ return null;
720
+ }
721
+ }
722
+
723
+ /**
724
+ * Lift the allocator to at least `floor`. Client cursors are max-only, so
725
+ * any value that can become a cursor (notably the seed's app-clock
726
+ * `updated_at` domain) must never exceed the allocator — a cursor above it
727
+ * would filter later, lower-allocated events out permanently. Failure is
728
+ * swallowed: alignment degrades to the same soft guarantee as the clock
729
+ * fallback.
730
+ */
731
+ private async alignVersionAllocator(floor: number): Promise<void> {
732
+ if (floor <= 0) return;
733
+ try {
734
+ if (!(await this.ensureSyncEventsTable())) return;
735
+ await this.getDb().execute({
736
+ sql: "UPDATE sync_version SET v = GREATEST(v, (?)::BIGINT) WHERE id = 1",
737
+ args: [floor],
738
+ });
739
+ } catch {
740
+ // Soft guarantee under DB failure, matching the clock fallback.
741
+ }
742
+ }
743
+
593
744
  async readMaxSyncEventVersion(): Promise<number> {
594
745
  if (!(await this.ensureSyncEventsTable())) return 0;
595
746
  try {
@@ -787,14 +938,111 @@ export class AppSyncState {
787
938
  },
788
939
  opts?: { dedupeKey?: string },
789
940
  ): void {
941
+ if (this.dbAssignedVersions && this.isPg() && !syncEventsDisabled()) {
942
+ // No provisional version may reach clients: cursors are max-only, so an
943
+ // emitted clock version above a later DB allocation would recreate the
944
+ // skew bug. Buffer/emit happen only after the DB returns the version,
945
+ // serialized so entries land in allocation order. The catch is a
946
+ // backstop: one rejected link must never poison the chain and silently
947
+ // drop every later event.
948
+ this.recordChain = this.recordChain
949
+ .then(() => this.recordWithDbVersion(event, opts?.dedupeKey))
950
+ .catch(() => {});
951
+ return;
952
+ }
790
953
  this.version = Math.max(this.version + 1, Date.now());
791
954
  const entry: ChangeEvent = { ...event, version: this.version };
955
+ this.commitEntry(entry);
956
+ void this.persistSyncEvent(entry, opts?.dedupeKey);
957
+ }
958
+
959
+ /** Buffer + emit. Shared by both version-allocation modes. */
960
+ private commitEntry(entry: ChangeEvent): void {
792
961
  this.buffer.push(entry);
793
962
  if (this.buffer.length > MAX_BUFFER) {
794
963
  this.buffer.splice(0, this.buffer.length - MAX_BUFFER);
795
964
  }
796
965
  this.pollEmitter.emit(POLL_CHANGE_EVENT, entry);
797
- void this.persistSyncEvent(entry, opts?.dedupeKey);
966
+ }
967
+
968
+ private async recordWithDbVersion(
969
+ event: { source: string; type: string; key?: string; [k: string]: unknown },
970
+ dedupeKey?: string,
971
+ ): Promise<void> {
972
+ // One id for both the allocating attempt AND any fallback persist: if the
973
+ // allocating INSERT commits server-side but the call fails (e.g. a query
974
+ // timeout after commit), the fallback's ON CONFLICT dedupes against the
975
+ // committed row instead of writing the same event twice under two ids.
976
+ // Deterministic ids exclude version by design; the random fallback id gets
977
+ // a wall-clock prefix instead of its usual version prefix (the prefix is
978
+ // debuggability, not semantics — nothing parses row ids).
979
+ const id =
980
+ this.deterministicEventIds && dedupeKey !== undefined
981
+ ? this.durableEventId(
982
+ { ...event, version: 0 } as ChangeEvent,
983
+ dedupeKey,
984
+ )
985
+ : `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
986
+ let version: number | null = null;
987
+ try {
988
+ version = await this.persistWithDbAssignedVersion(event, id);
989
+ } catch {
990
+ version = null;
991
+ }
992
+ if (version == null) {
993
+ // The statement may have COMMITTED even though the call failed (e.g. a
994
+ // timeout after commit). Recover the durable row's version by the shared
995
+ // id so clients see the allocator-assigned value, never a divergent
996
+ // clock one.
997
+ version = await this.recoverCommittedVersion(id);
998
+ }
999
+ if (version == null) {
1000
+ // Availability fallback: a DB blip must not stall the in-process fast
1001
+ // path. Clock-allocate and emit — but first make a best effort to lift
1002
+ // the allocator to the fallback value, so other writers' next
1003
+ // allocations land above the cursors this emit will advance (max-only
1004
+ // cursors above the allocator would filter those events permanently).
1005
+ // If the DB is down the lift fails too; the ordering guarantee is soft
1006
+ // exactly while the DB is unhealthy.
1007
+ this.dbVersionFallbacks++;
1008
+ if (this.dbVersionFallbacks === 1) {
1009
+ console.warn(
1010
+ "[agent-native] sync version allocation failed; falling back to clock-assigned versions",
1011
+ );
1012
+ }
1013
+ this.version = Math.max(this.version + 1, Date.now());
1014
+ const entry: ChangeEvent = { ...event, version: this.version };
1015
+ await this.alignVersionAllocator(this.version);
1016
+ this.commitEntryForChain(entry);
1017
+ void this.persistSyncEvent(entry, dedupeKey, id);
1018
+ return;
1019
+ }
1020
+ // A deterministic-id dedupe loser adopts the winner's (possibly older)
1021
+ // version here — buffer filtering is a full scan, so a non-tail version is
1022
+ // delivered correctly and the version-keyed combined-read dedupe collapses
1023
+ // it against the durable row.
1024
+ this.version = Math.max(this.version, version);
1025
+ this.commitEntryForChain({ ...event, version });
1026
+ }
1027
+
1028
+ /**
1029
+ * `commitEntry` for the deferred (chained) path: the buffer push must land
1030
+ * and a throwing pollEmitter listener must not reject the chain — the
1031
+ * synchronous path propagates such throws to the recordChange caller, but
1032
+ * here there is no caller to propagate to, only the chain to poison.
1033
+ */
1034
+ private commitEntryForChain(entry: ChangeEvent): void {
1035
+ try {
1036
+ this.commitEntry(entry);
1037
+ } catch (err) {
1038
+ if (!this.warnedListenerThrow) {
1039
+ this.warnedListenerThrow = true;
1040
+ console.warn(
1041
+ "[agent-native] poll listener threw during deferred emit",
1042
+ err,
1043
+ );
1044
+ }
1045
+ }
798
1046
  }
799
1047
 
800
1048
  private recordExtensionChanges(
@@ -1079,9 +1327,7 @@ export class AppSyncState {
1079
1327
  refreshTs = Math.max(refreshTs, timestampValue(row.updated_at));
1080
1328
  }
1081
1329
 
1082
- // Seed version — never decrease an already-set value
1083
- this.version = Math.max(
1084
- this.version,
1330
+ const seedMax = Math.max(
1085
1331
  syncEventsTs,
1086
1332
  appTs,
1087
1333
  settingsTs,
@@ -1089,6 +1335,15 @@ export class AppSyncState {
1089
1335
  extensionMarkerTs,
1090
1336
  actionMarkerTs,
1091
1337
  );
1338
+ // The seed mixes app-clock `updated_at` values that can sit AHEAD of the
1339
+ // allocator (a skew-fast writer's rows). Lift the allocator to the seed
1340
+ // BEFORE the seed can reach this.version — and through it, client
1341
+ // cursors — so no later allocation lands below a seeded cursor.
1342
+ if (this.dbAssignedVersions && this.isPg() && !syncEventsDisabled()) {
1343
+ await this.alignVersionAllocator(seedMax);
1344
+ }
1345
+ // Seed version — never decrease an already-set value
1346
+ this.version = Math.max(this.version, seedMax);
1092
1347
 
1093
1348
  // Set baselines so checkExternalDbChanges detects future writes
1094
1349
  this.lastAppStateTs = appTs;
@@ -1135,9 +1390,20 @@ export class AppSyncState {
1135
1390
  // second overlapping run that would double-advance the watermarks.
1136
1391
  if (this.checkPromise) return this.checkPromise;
1137
1392
  this.lastDbCheck = now;
1138
- this.checkPromise = this.doCheckExternalDbChanges().finally(() => {
1139
- this.checkPromise = null;
1140
- });
1393
+ this.checkPromise = this.doCheckExternalDbChanges()
1394
+ .then(() => {
1395
+ // Gated: the detector's recordChange calls only SCHEDULED allocations.
1396
+ // The poll handler awaits this check so detected cross-process writes
1397
+ // land in the same response — drain the chain here to keep that
1398
+ // contract (and so a serverless instance frozen after responding
1399
+ // can't strand them). The chain never rejects (backstopped).
1400
+ if (this.dbAssignedVersions && this.isPg() && !syncEventsDisabled()) {
1401
+ return this.recordChain;
1402
+ }
1403
+ })
1404
+ .finally(() => {
1405
+ this.checkPromise = null;
1406
+ });
1141
1407
  return this.checkPromise;
1142
1408
  }
1143
1409
 
@@ -1361,12 +1627,32 @@ export class AppSyncState {
1361
1627
 
1362
1628
  let _defaultState: AppSyncState | undefined;
1363
1629
 
1630
+ /**
1631
+ * Hosted-realtime gate for the default instance, env-only. Mirrors the
1632
+ * fail-closed transport-AND-url pair in `sentry-config.ts`'s
1633
+ * `resolveRealtimeClientConfig` (not imported — poll.ts is import-cycle
1634
+ * sensitive). Hosted apps are multi-writer (their own serverless instances
1635
+ * plus gateway instances share one DB), so THEY must DB-allocate versions too
1636
+ * — gating only the gateway would leave the app's user-event writes
1637
+ * clock-versioned and the cross-writer skew hole open.
1638
+ */
1639
+ function hostedRealtimeTransportEnabled(): boolean {
1640
+ return (
1641
+ process.env.AGENT_NATIVE_REALTIME_TRANSPORT?.trim() === "hosted" &&
1642
+ !!process.env.AGENT_NATIVE_REALTIME_GATEWAY_URL?.trim()
1643
+ );
1644
+ }
1645
+
1364
1646
  /**
1365
1647
  * The process-wide default instance, bound to the global DB. All module-level
1366
1648
  * exports delegate here so self-hosted apps run exactly one code path.
1367
1649
  */
1368
1650
  export function getDefaultAppSyncState(): AppSyncState {
1369
- if (!_defaultState) _defaultState = new AppSyncState();
1651
+ if (!_defaultState) {
1652
+ _defaultState = new AppSyncState({
1653
+ dbAssignedVersions: hostedRealtimeTransportEnabled(),
1654
+ });
1655
+ }
1370
1656
  return _defaultState;
1371
1657
  }
1372
1658
 
@@ -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
+ }