@agent-native/core 0.116.0 → 0.117.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 (96) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +15 -0
  3. package/corpus/core/package.json +4 -1
  4. package/corpus/core/src/client/analytics.ts +7 -0
  5. package/corpus/core/src/client/use-db-sync.ts +354 -15
  6. package/corpus/core/src/collab/client.ts +26 -2
  7. package/corpus/core/src/db/ddl-guard.ts +49 -10
  8. package/corpus/core/src/deploy/build.ts +25 -2
  9. package/corpus/core/src/realtime-protocol.ts +92 -0
  10. package/corpus/core/src/server/core-routes-plugin.ts +7 -0
  11. package/corpus/core/src/server/poll-events.ts +29 -8
  12. package/corpus/core/src/server/poll.ts +1123 -865
  13. package/corpus/core/src/server/realtime-token.ts +106 -0
  14. package/corpus/core/src/server/sentry-config.ts +40 -0
  15. package/corpus/core/src/server/short-lived-token.ts +156 -0
  16. package/corpus/core/src/server/sse.ts +15 -0
  17. package/corpus/core/src/server/ssr-handler.ts +9 -3
  18. package/corpus/templates/clips/app/components/sharing/share-ui.tsx +12 -0
  19. package/corpus/templates/clips/app/i18n/en-US.ts +1 -0
  20. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +566 -299
  21. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +14 -0
  22. package/corpus/templates/content/app/i18n-data.ts +90 -0
  23. package/dist/client/analytics.d.ts +10 -0
  24. package/dist/client/analytics.d.ts.map +1 -1
  25. package/dist/client/analytics.js.map +1 -1
  26. package/dist/client/use-db-sync.d.ts +1 -1
  27. package/dist/client/use-db-sync.d.ts.map +1 -1
  28. package/dist/client/use-db-sync.js +315 -15
  29. package/dist/client/use-db-sync.js.map +1 -1
  30. package/dist/collab/client.d.ts.map +1 -1
  31. package/dist/collab/client.js +26 -2
  32. package/dist/collab/client.js.map +1 -1
  33. package/dist/db/ddl-guard.d.ts +7 -2
  34. package/dist/db/ddl-guard.d.ts.map +1 -1
  35. package/dist/db/ddl-guard.js +21 -9
  36. package/dist/db/ddl-guard.js.map +1 -1
  37. package/dist/deploy/build.d.ts.map +1 -1
  38. package/dist/deploy/build.js +25 -2
  39. package/dist/deploy/build.js.map +1 -1
  40. package/dist/notifications/routes.d.ts +3 -3
  41. package/dist/observability/routes.d.ts +5 -5
  42. package/dist/progress/routes.d.ts +1 -1
  43. package/dist/provider-api/actions/custom-provider-registration.d.ts +4 -4
  44. package/dist/provider-api/actions/provider-api.d.ts +6 -6
  45. package/dist/realtime-protocol.d.ts +40 -0
  46. package/dist/realtime-protocol.d.ts.map +1 -0
  47. package/dist/realtime-protocol.js +69 -0
  48. package/dist/realtime-protocol.js.map +1 -0
  49. package/dist/resources/handlers.d.ts +1 -1
  50. package/dist/secrets/routes.d.ts +3 -3
  51. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  52. package/dist/server/core-routes-plugin.js +3 -0
  53. package/dist/server/core-routes-plugin.js.map +1 -1
  54. package/dist/server/poll-events.d.ts +7 -1
  55. package/dist/server/poll-events.d.ts.map +1 -1
  56. package/dist/server/poll-events.js +24 -7
  57. package/dist/server/poll-events.js.map +1 -1
  58. package/dist/server/poll.d.ts +223 -42
  59. package/dist/server/poll.d.ts.map +1 -1
  60. package/dist/server/poll.js +849 -737
  61. package/dist/server/poll.js.map +1 -1
  62. package/dist/server/realtime-token.d.ts +34 -0
  63. package/dist/server/realtime-token.d.ts.map +1 -0
  64. package/dist/server/realtime-token.js +85 -0
  65. package/dist/server/realtime-token.js.map +1 -0
  66. package/dist/server/sentry-config.d.ts +12 -0
  67. package/dist/server/sentry-config.d.ts.map +1 -1
  68. package/dist/server/sentry-config.js +34 -0
  69. package/dist/server/sentry-config.js.map +1 -1
  70. package/dist/server/short-lived-token.d.ts +46 -0
  71. package/dist/server/short-lived-token.d.ts.map +1 -1
  72. package/dist/server/short-lived-token.js +104 -0
  73. package/dist/server/short-lived-token.js.map +1 -1
  74. package/dist/server/sse.d.ts +1 -1
  75. package/dist/server/sse.d.ts.map +1 -1
  76. package/dist/server/sse.js +4 -0
  77. package/dist/server/sse.js.map +1 -1
  78. package/dist/server/ssr-handler.d.ts.map +1 -1
  79. package/dist/server/ssr-handler.js +5 -3
  80. package/dist/server/ssr-handler.js.map +1 -1
  81. package/dist/server/transcribe-voice.d.ts +1 -1
  82. package/package.json +4 -1
  83. package/src/client/analytics.ts +7 -0
  84. package/src/client/use-db-sync.ts +354 -15
  85. package/src/collab/client.ts +26 -2
  86. package/src/db/ddl-guard.ts +49 -10
  87. package/src/deploy/build.ts +25 -2
  88. package/src/realtime-protocol.ts +92 -0
  89. package/src/server/core-routes-plugin.ts +7 -0
  90. package/src/server/poll-events.ts +29 -8
  91. package/src/server/poll.ts +1123 -865
  92. package/src/server/realtime-token.ts +106 -0
  93. package/src/server/sentry-config.ts +40 -0
  94. package/src/server/short-lived-token.ts +156 -0
  95. package/src/server/sse.ts +15 -0
  96. package/src/server/ssr-handler.ts +9 -3
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Realtime subscribe-token mint endpoint.
3
+ *
4
+ * `GET /_agent-native/realtime-token` — the one Netlify request per page load
5
+ * that the hosted Realtime Gateway path needs. The SSR HTML/`.data` shell is a
6
+ * single impersonal, CDN-cached document (`guard:ssr-cache-shell`), so a
7
+ * per-visitor token cannot be baked into the page; the client mints it here
8
+ * after load. Same-origin + session-gated; sessionless requests get 401.
9
+ *
10
+ * The signed token binds this app's Builder project id (the gateway channel)
11
+ * and carries the app's own end-user identity (`owner` = session email, `orgId`
12
+ * = framework org) — the exact tuple `recordChange` stamps onto `sync_events`
13
+ * and the gateway feeds to `canSeeChangeForUser`. It is signed with the app's
14
+ * per-project HMAC secret, injected as a reserved env var at provision time.
15
+ */
16
+
17
+ import {
18
+ defineEventHandler,
19
+ getMethod,
20
+ type H3Event,
21
+ setResponseHeader,
22
+ setResponseStatus,
23
+ } from "h3";
24
+
25
+ import { getOrgContext } from "../org/context.js";
26
+ import { getSession } from "./auth.js";
27
+ import { resolveBuilderBranchProjectId } from "./builder-browser.js";
28
+ import { runWithRequestContext } from "./request-context.js";
29
+ import { isSameOriginRequest } from "./request-origin.js";
30
+ import { signRealtimeSubscribeToken } from "./short-lived-token.js";
31
+
32
+ /**
33
+ * Reserved env var holding the app's per-project HMAC secret. Injected by the
34
+ * Builder provisioning path (`SYSTEM_RESERVED_KEYS` + prod allowlist); see the
35
+ * Agent-Native Realtime Sync tech spec.
36
+ */
37
+ export const REALTIME_HMAC_SECRET_ENV = "AGENT_NATIVE_REALTIME_HMAC_SECRET";
38
+
39
+ /** Short TTL — validated at connect; the gateway rotates over the stream. */
40
+ const REALTIME_TOKEN_TTL_SECONDS = 600;
41
+
42
+ export function getRealtimeSigningSecret(): string | undefined {
43
+ return process.env[REALTIME_HMAC_SECRET_ENV]?.trim() || undefined;
44
+ }
45
+
46
+ export function createRealtimeTokenHandler() {
47
+ return defineEventHandler(async (event: H3Event) => {
48
+ // Identity-bearing token, valid ~10 min — never cacheable by the browser or
49
+ // any intermediary. Set once up front so every return path carries it.
50
+ setResponseHeader(event, "Cache-Control", "private, no-store");
51
+
52
+ if (getMethod(event) !== "GET") {
53
+ setResponseStatus(event, 405);
54
+ return { error: "Method not allowed" };
55
+ }
56
+ if (!isSameOriginRequest(event)) {
57
+ setResponseStatus(event, 403);
58
+ return { error: "Cross-origin request rejected" };
59
+ }
60
+
61
+ const session = await getSession(event).catch(() => null);
62
+ if (!session?.email) {
63
+ setResponseStatus(event, 401);
64
+ return { error: "Authentication required" };
65
+ }
66
+
67
+ const orgCtx = await getOrgContext(event).catch(() => null);
68
+ const requestContext = {
69
+ userEmail: session.email,
70
+ orgId: orgCtx?.orgId ?? session.orgId,
71
+ };
72
+
73
+ // The scoped-secret fallback inside resolveBuilderBranchProjectId reads the
74
+ // request-context ALS (resolveSecret -> getRequestUserEmail); without it the
75
+ // user/org/workspace scopes silently no-op and only env vars resolve. Wrap
76
+ // the resolution like google-realtime-session.ts does.
77
+ return runWithRequestContext(requestContext, async () => {
78
+ // Async resolver so hosted apps whose project id lives in a
79
+ // request-scoped app/org/workspace secret (not an env var) also work —
80
+ // the sync env-only lookup would 404 them and silently drop the gateway.
81
+ const projectId = await resolveBuilderBranchProjectId();
82
+ const secret = getRealtimeSigningSecret();
83
+ if (!projectId || !secret) {
84
+ // Hosted realtime isn't provisioned for this app. 404 lets the client
85
+ // fall back to the app's own /_agent-native/poll without treating it
86
+ // as an auth failure.
87
+ setResponseStatus(event, 404);
88
+ return { error: "Realtime gateway not configured" };
89
+ }
90
+
91
+ const token = signRealtimeSubscribeToken(
92
+ {
93
+ projectId,
94
+ owner: session.email,
95
+ orgId: requestContext.orgId,
96
+ ttlSeconds: REALTIME_TOKEN_TTL_SECONDS,
97
+ },
98
+ secret,
99
+ );
100
+ const expiresAt = new Date(
101
+ Date.now() + REALTIME_TOKEN_TTL_SECONDS * 1000,
102
+ ).toISOString();
103
+ return { token, expiresAt, ttlSeconds: REALTIME_TOKEN_TTL_SECONDS };
104
+ });
105
+ });
106
+ }
@@ -71,3 +71,43 @@ export function getSentryClientConfigScript(): string | null {
71
71
  "</script>",
72
72
  ].join("");
73
73
  }
74
+
75
+ /**
76
+ * Hosted Realtime Gateway config for the client, or null for the in-process
77
+ * (local) transport. Values are env-derived and identical for every visitor,
78
+ * so this is safe inside the CDN-cached SSR shell (see `guard:ssr-cache-shell`).
79
+ * The per-user subscribe token is NOT here — it is minted client-side after
80
+ * load from `/_agent-native/realtime-token`.
81
+ */
82
+ export function resolveRealtimeClientConfig(): {
83
+ transport: "hosted";
84
+ gatewayBaseUrl: string;
85
+ } | null {
86
+ // Fail closed: emit hosted config only when BOTH the transport is hosted AND
87
+ // an explicit gateway URL is set. No production default — this ships into the
88
+ // CDN-cached shell served to every visitor, so a mis-set staging/preview/
89
+ // self-hosted env must stay on the local transport rather than silently
90
+ // point every browser at api.builder.io. This gating is mirrored byte-for-
91
+ // byte in the worker emitter in `deploy/build.ts` (kept in sync deliberately).
92
+ if (firstNonEmpty(process.env.AGENT_NATIVE_REALTIME_TRANSPORT) !== "hosted") {
93
+ return null;
94
+ }
95
+ const gatewayBaseUrl = firstNonEmpty(
96
+ process.env.AGENT_NATIVE_REALTIME_GATEWAY_URL,
97
+ );
98
+ if (!gatewayBaseUrl) return null;
99
+ return { transport: "hosted", gatewayBaseUrl };
100
+ }
101
+
102
+ export function getRealtimeClientConfigScript(): string | null {
103
+ const realtime = resolveRealtimeClientConfig();
104
+ if (!realtime) return null;
105
+
106
+ return [
107
+ "<script data-agent-native-realtime-config>",
108
+ "window.__AGENT_NATIVE_CONFIG__=Object.assign({},window.__AGENT_NATIVE_CONFIG__,",
109
+ JSON.stringify({ realtime }),
110
+ ");",
111
+ "</script>",
112
+ ].join("");
113
+ }
@@ -168,3 +168,159 @@ export function verifyShortLivedToken(
168
168
 
169
169
  return { ok: true, viewerEmail: claims.viewerEmail };
170
170
  }
171
+
172
+ // ── Realtime subscribe tokens ────────────────────────────────────────────────
173
+ //
174
+ // An identity-bearing extension of the same HMAC discipline, used by the hosted
175
+ // Realtime Gateway. Differs from the media token above in three ways the gateway
176
+ // depends on:
177
+ // 1. It is signed with a caller-supplied PER-PROJECT key (the app's HMAC
178
+ // secret), not the single deployment-wide `getSigningKey()`. The app server
179
+ // mints with it; the gateway (a second issuer for stream rotation) verifies
180
+ // and re-mints with the same per-project key. A token minted for project A
181
+ // fails signature verification against project B's key.
182
+ // 2. It carries authorization-bearing identity (`owner`/`orgId`) that the
183
+ // gateway feeds to `canSeeChangeForUser` — the media token's `viewerEmail`
184
+ // is audit-only by contract, so a new field is required, not repurposed.
185
+ // 3. It binds `projectId` as the channel and stamps a `typ` discriminator, so
186
+ // it is verified against the connect channel and cannot be replayed as a
187
+ // media token (or vice-versa) even if keys ever overlapped.
188
+
189
+ /** Payload `typ` discriminator for realtime subscribe tokens. */
190
+ export const REALTIME_SUBSCRIBE_TOKEN_TYPE = "rt-subscribe";
191
+ const DEFAULT_REALTIME_TTL_SECONDS = 600;
192
+
193
+ /** Inputs for {@link signRealtimeSubscribeToken}. */
194
+ export interface RealtimeSubscribeClaims {
195
+ /** Channel — one Neon project per app. Verified at connect, not just carried. */
196
+ projectId: string;
197
+ /**
198
+ * App end-user session email (NOT a Builder.io account — see the tech spec).
199
+ * Fed to `canSeeChangeForUser`. Present in v0.
200
+ */
201
+ owner?: string;
202
+ /** Framework org id for the app's end-user. */
203
+ orgId?: string;
204
+ /** Override default TTL (seconds). */
205
+ ttlSeconds?: number;
206
+ }
207
+
208
+ interface DecodedRealtimeClaims {
209
+ typ: string;
210
+ projectId: string;
211
+ owner?: string;
212
+ orgId?: string;
213
+ exp: number;
214
+ }
215
+
216
+ /**
217
+ * Result of {@link verifyRealtimeSubscribeToken}. On success it returns the
218
+ * identity claims the gateway uses to scope delivery.
219
+ */
220
+ export type RealtimeVerifyResult =
221
+ | {
222
+ ok: true;
223
+ projectId: string;
224
+ owner?: string;
225
+ orgId?: string;
226
+ exp: number;
227
+ }
228
+ | { ok: false; reason: string };
229
+
230
+ function hmacB64(payloadStr: string, key: string): string {
231
+ return base64UrlEncode(
232
+ crypto.createHmac("sha256", key).update(payloadStr).digest(),
233
+ );
234
+ }
235
+
236
+ function timingSafeEqualB64(sig: string, expected: string): boolean {
237
+ const sigBuf = Buffer.from(sig, "utf8");
238
+ const expBuf = Buffer.from(expected, "utf8");
239
+ if (sigBuf.length !== expBuf.length) {
240
+ crypto.timingSafeEqual(expBuf, expBuf); // burn ~equal cycles
241
+ return false;
242
+ }
243
+ return crypto.timingSafeEqual(sigBuf, expBuf);
244
+ }
245
+
246
+ /**
247
+ * Mint a realtime subscribe token for `claims.projectId`, signed with the
248
+ * app's per-project `key`. Safe to place on the connect query string (short
249
+ * TTL, single-purpose, channel-bound). Verified by
250
+ * {@link verifyRealtimeSubscribeToken} at connect.
251
+ */
252
+ export function signRealtimeSubscribeToken(
253
+ claims: RealtimeSubscribeClaims,
254
+ key: string,
255
+ ): string {
256
+ if (!key) throw new Error("signRealtimeSubscribeToken requires a key");
257
+ // Fail closed: a token with neither owner nor orgId carries no authorization
258
+ // identity, so canSeeChangeForUser would only ever match global/unowned
259
+ // events. Every issuer (mint endpoint + the gateway's rotation re-mint) must
260
+ // supply at least one identity claim.
261
+ if (!claims.owner && !claims.orgId) {
262
+ throw new Error(
263
+ "signRealtimeSubscribeToken requires an owner or orgId claim",
264
+ );
265
+ }
266
+ const ttl = claims.ttlSeconds ?? DEFAULT_REALTIME_TTL_SECONDS;
267
+ const payload: DecodedRealtimeClaims = {
268
+ typ: REALTIME_SUBSCRIBE_TOKEN_TYPE,
269
+ projectId: claims.projectId,
270
+ exp: Math.floor(Date.now() / 1000) + ttl,
271
+ };
272
+ if (claims.owner) payload.owner = claims.owner;
273
+ if (claims.orgId) payload.orgId = claims.orgId;
274
+
275
+ const payloadStr = base64UrlEncode(JSON.stringify(payload));
276
+ return `${payloadStr}.${hmacB64(payloadStr, key)}`;
277
+ }
278
+
279
+ /**
280
+ * Verify a realtime subscribe token against the app's per-project `key` and the
281
+ * connect channel `projectId`. Returns the identity claims only when the shape,
282
+ * signature (constant-time), `typ`, `exp`, and `projectId` binding all hold.
283
+ */
284
+ export function verifyRealtimeSubscribeToken(
285
+ token: string,
286
+ expected: { projectId: string; key: string },
287
+ ): RealtimeVerifyResult {
288
+ if (!expected.key) return { ok: false, reason: "no_key" };
289
+ if (typeof token !== "string" || !token.includes(".")) {
290
+ return { ok: false, reason: "malformed" };
291
+ }
292
+ const [payloadStr, sig] = token.split(".", 2);
293
+ if (!payloadStr || !sig) return { ok: false, reason: "malformed" };
294
+
295
+ if (!timingSafeEqualB64(sig, hmacB64(payloadStr, expected.key))) {
296
+ return { ok: false, reason: "bad_signature" };
297
+ }
298
+
299
+ let claims: DecodedRealtimeClaims;
300
+ try {
301
+ claims = JSON.parse(base64UrlDecode(payloadStr).toString("utf8"));
302
+ } catch {
303
+ return { ok: false, reason: "bad_payload" };
304
+ }
305
+
306
+ if (claims.typ !== REALTIME_SUBSCRIBE_TOKEN_TYPE) {
307
+ return { ok: false, reason: "wrong_type" };
308
+ }
309
+ if (typeof claims.exp !== "number") {
310
+ return { ok: false, reason: "bad_payload" };
311
+ }
312
+ if (claims.exp * 1000 < Date.now()) {
313
+ return { ok: false, reason: "expired" };
314
+ }
315
+ if (claims.projectId !== expected.projectId) {
316
+ return { ok: false, reason: "wrong_project" };
317
+ }
318
+
319
+ return {
320
+ ok: true,
321
+ projectId: claims.projectId,
322
+ owner: claims.owner,
323
+ orgId: claims.orgId,
324
+ exp: claims.exp,
325
+ };
326
+ }
@@ -1,5 +1,20 @@
1
1
  import { defineEventHandler, createEventStream } from "h3";
2
2
 
3
+ // Re-export the wire protocol so server consumers (the hosted Realtime Gateway
4
+ // in ai-services) get the frame contract from the same `./server/sse` subpath.
5
+ // The browser client imports it directly from `../realtime-protocol.js`.
6
+ export {
7
+ buildHandshakeFrame,
8
+ parseHandshakeFrame,
9
+ parseTokenFrame,
10
+ REALTIME_CAP_NO_AWARENESS,
11
+ REALTIME_PROTOCOL_VERSION,
12
+ REALTIME_SSE_HANDSHAKE_EVENT,
13
+ REALTIME_SSE_TOKEN_EVENT,
14
+ type RealtimeHandshake,
15
+ type RealtimeTokenFrame,
16
+ } from "../realtime-protocol.js";
17
+
3
18
  /** Any object with on/off methods (compatible with EventEmitter, TypedEventEmitter, etc.). */
4
19
  interface EventLike {
5
20
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -37,7 +37,10 @@ import {
37
37
  } from "./app-base-path.js";
38
38
  import { captureError } from "./capture-error.js";
39
39
  import { runWithRequestContext } from "./request-context.js";
40
- import { getSentryClientConfigScript } from "./sentry-config.js";
40
+ import {
41
+ getRealtimeClientConfigScript,
42
+ getSentryClientConfigScript,
43
+ } from "./sentry-config.js";
41
44
 
42
45
  export {
43
46
  DEFAULT_SSR_CACHE_HEADERS,
@@ -342,7 +345,10 @@ async function rewriteMountedResponse(
342
345
  pathname: string,
343
346
  requestUrl: string,
344
347
  ): Promise<Response> {
345
- const sentryClientConfigScript = getSentryClientConfigScript();
348
+ const clientConfigScript =
349
+ [getSentryClientConfigScript(), getRealtimeClientConfigScript()]
350
+ .filter(Boolean)
351
+ .join("") || null;
346
352
  const headers = new Headers(response.headers);
347
353
  applyDefaultSsrCacheHeader(headers, response.status, pathname);
348
354
  applyDefaultSpeculationRulesHeader(headers, response.status, basePath);
@@ -377,7 +383,7 @@ async function rewriteMountedResponse(
377
383
  prefixMountedHtml(html, basePath),
378
384
  defaultSocialImageUrl(requestUrl, basePath),
379
385
  ),
380
- sentryClientConfigScript,
386
+ clientConfigScript,
381
387
  ),
382
388
  {
383
389
  status: response.status,
@@ -7,6 +7,7 @@ import {
7
7
  IconCheck,
8
8
  IconCopy,
9
9
  IconLock,
10
+ IconSend2,
10
11
  IconTrash,
11
12
  IconUsersGroup,
12
13
  IconWorld,
@@ -414,6 +415,17 @@ export function SharePeopleTab({
414
415
  ))}
415
416
  </SelectContent>
416
417
  </Select>
418
+ <Button
419
+ type="button"
420
+ size="icon"
421
+ onClick={handleAdd}
422
+ disabled={!hasInviteEmail || share.isPending}
423
+ aria-label={t("shareUi.invite")}
424
+ title={t("shareUi.invite")}
425
+ className="h-9 w-9 shrink-0"
426
+ >
427
+ <IconSend2 size={16} />
428
+ </Button>
417
429
  </div>
418
430
  {hasInviteEmail ? (
419
431
  <label className="flex items-center gap-2 text-xs text-muted-foreground">
@@ -481,6 +481,7 @@ const messages = {
481
481
  makePublicAndCopy: "Make public and copy",
482
482
  copy: "Copy",
483
483
  addPeopleByEmail: "Add people by email",
484
+ invite: "Invite",
484
485
  notifyPeople: "Notify people",
485
486
  peopleWithAccess: "People with access",
486
487
  ownerRole: "Owner",