@agent-native/core 0.172.6 → 0.172.9

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.
package/corpus/README.md CHANGED
@@ -31,4 +31,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
31
31
 
32
32
  ## Generated Counts
33
33
 
34
- - template files: 8594
34
+ - template files: 8596
@@ -0,0 +1,5 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-08-24
4
+ ---
5
+ Analytics chat drafts now stay in place while a new chat finishes loading.
@@ -0,0 +1,5 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-08-24
4
+ ---
5
+ Muted microphone alerts now return you to recording setup before recording starts.
@@ -1087,8 +1087,6 @@ export function App({
1087
1087
  );
1088
1088
  const [micOn, setMicOn] = useState<boolean>(() => loadBool(MIC_ON_KEY, true));
1089
1089
  const [micOffConfirmOpen, setMicOffConfirmOpen] = useState(false);
1090
- const pendingStartOptionsRef =
1091
- useRef<Parameters<typeof handleStartRecording>[0]>(undefined);
1092
1090
  const [systemAudioOn, setSystemAudioOn] = useState<boolean>(() =>
1093
1091
  loadBool(SYSTEM_AUDIO_KEY, true),
1094
1092
  );
@@ -3591,16 +3589,14 @@ export function App({
3591
3589
  }, []);
3592
3590
 
3593
3591
  // Gates every start-recording gesture (button, global shortcut, permission
3594
- // retry) on the mic toggle. When the mic is off we hold the actual
3595
- // getDisplayMedia/getUserMedia call until the user confirms in
3596
- // micOffConfirmOpen — the confirm button's own click supplies the user
3597
- // activation handleStartRecording needs, same as the direct gesture would.
3592
+ // retry) on the mic toggle. When the mic is off we show the informational
3593
+ // mic-off screen and wait for the user to go back and change that setting
3594
+ // before the actual getDisplayMedia/getUserMedia call runs.
3598
3595
  function beginRecording(
3599
3596
  options?: Parameters<typeof handleStartRecording>[0],
3600
3597
  beginOptions?: { revealPopoverIfMicOff?: boolean },
3601
3598
  ) {
3602
3599
  if (!micOn) {
3603
- pendingStartOptionsRef.current = options;
3604
3600
  if (beginOptions?.revealPopoverIfMicOff) {
3605
3601
  invoke("show_popover").catch(() => {});
3606
3602
  }
@@ -3611,21 +3607,9 @@ export function App({
3611
3607
  }
3612
3608
 
3613
3609
  function closeMicOffConfirmation() {
3614
- pendingStartOptionsRef.current = undefined;
3615
3610
  setMicOffConfirmOpen(false);
3616
3611
  }
3617
3612
 
3618
- function unmuteFromConfirmation() {
3619
- setMicOn(true);
3620
- closeMicOffConfirmation();
3621
- }
3622
-
3623
- function continueWithoutMic() {
3624
- const options = pendingStartOptionsRef.current;
3625
- closeMicOffConfirmation();
3626
- void handleStartRecording(options);
3627
- }
3628
-
3629
3613
  recordShortcutHandlerRef.current = () => {
3630
3614
  if (recorder) {
3631
3615
  emit("clips:recorder-stop").catch(() => {});
@@ -4319,11 +4303,7 @@ export function App({
4319
4303
  return (
4320
4304
  <div className="app app-recorder" ref={appRef}>
4321
4305
  {micOffConfirmOpen ? (
4322
- <MicOffConfirmation
4323
- onBack={closeMicOffConfirmation}
4324
- onUnmute={unmuteFromConfirmation}
4325
- onContinue={continueWithoutMic}
4326
- />
4306
+ <MicOffConfirmation onBack={closeMicOffConfirmation} />
4327
4307
  ) : null}
4328
4308
 
4329
4309
  <div
@@ -1,14 +1,6 @@
1
1
  import { IconArrowLeft, IconMicrophone2 } from "@tabler/icons-react";
2
2
 
3
- export function MicOffConfirmation({
4
- onBack,
5
- onUnmute,
6
- onContinue,
7
- }: {
8
- onBack: () => void;
9
- onUnmute: () => void;
10
- onContinue: () => void;
11
- }) {
3
+ export function MicOffConfirmation({ onBack }: { onBack: () => void }) {
12
4
  return (
13
5
  <section className="mic-off-confirmation" aria-labelledby="mic-off-title">
14
6
  <header className="mic-off-confirmation-header">
@@ -29,19 +21,10 @@ export function MicOffConfirmation({
29
21
  </div>
30
22
  <h2 id="mic-off-title">Your mic is muted</h2>
31
23
  <p>
32
- To have sound in your video, you&apos;ll need to unmute your
33
- microphone.
24
+ You can change your microphone setting and then come back to start
25
+ recording.
34
26
  </p>
35
27
  </div>
36
-
37
- <footer className="mic-off-confirmation-actions">
38
- <button type="button" className="secondary" onClick={onUnmute}>
39
- Unmute
40
- </button>
41
- <button type="button" className="primary" onClick={onContinue}>
42
- Continue
43
- </button>
44
- </footer>
45
28
  </section>
46
29
  );
47
30
  }
@@ -2177,26 +2177,6 @@ body[data-clips-route="recording-pill"] #root {
2177
2177
  line-height: 1.55;
2178
2178
  }
2179
2179
 
2180
- .mic-off-confirmation-actions {
2181
- display: grid;
2182
- grid-template-columns: repeat(2, minmax(0, 1fr));
2183
- gap: 10px;
2184
- padding: 16px 18px 18px;
2185
- border-top: 1px solid var(--border);
2186
- background: var(--surface);
2187
- }
2188
-
2189
- .mic-off-confirmation-actions .primary,
2190
- .mic-off-confirmation-actions .secondary {
2191
- width: auto;
2192
- min-width: 0;
2193
- height: 42px;
2194
- padding: 0 12px;
2195
- border-radius: var(--radius);
2196
- font-size: 13px;
2197
- white-space: nowrap;
2198
- }
2199
-
2200
2180
  /* ------------------------------------------------------------------------- */
2201
2181
  /* Alert dialog */
2202
2182
  /* ------------------------------------------------------------------------- */
@@ -1458,7 +1458,7 @@ const AssistantChatInner = forwardRef(function AssistantChatInner({ emptyStateTe
1458
1458
  // Cleared on the next message send.
1459
1459
  const [composerError, setComposerError] = useState(null);
1460
1460
  const [composerText, setComposerText] = useState("");
1461
- const composerDraftScope = threadId || tabId;
1461
+ const composerDraftScope = tabId || threadId;
1462
1462
  const initialComposerText = useMemo(() => readAssistantChatComposerDraft(composerDraftScope), [composerDraftScope]);
1463
1463
  const handleComposerTextChange = useCallback((text) => {
1464
1464
  setComposerText(text);
@@ -20,6 +20,7 @@ export declare function isWorkspaceAppEnvironment(env?: RuntimeEnv): boolean;
20
20
  export declare function dispatchOverviewHref(apps: OrgSwitcherAppLink[], env?: RuntimeEnv): string;
21
21
  export declare function dispatchAppsHref(apps: OrgSwitcherAppLink[], env?: RuntimeEnv): string;
22
22
  export declare function visibleOrgAppLinks(apps: OrgSwitcherAppLink[], max?: number): VisibleOrgAppLinks;
23
+ export declare function workspaceAppFetchUrls(env: RuntimeEnv): string[];
23
24
  export interface UseOrgSwitcherAppLinksResult {
24
25
  apps: OrgSwitcherAppLink[];
25
26
  isWorkspace: boolean;
@@ -203,7 +203,7 @@ export function visibleOrgAppLinks(apps, max = ORG_SWITCHER_MAX_APP_LINKS) {
203
203
  };
204
204
  }
205
205
  function initialWorkspaceLinks(env) {
206
- return (parseWorkspaceAppLinksJson(envString(env, "VITE_AGENT_NATIVE_WORKSPACE_APPS_JSON"), env) ?? [
206
+ return [
207
207
  {
208
208
  id: DISPATCH_ID,
209
209
  name: "Dispatch",
@@ -213,20 +213,31 @@ function initialWorkspaceLinks(env) {
213
213
  isDispatch: true,
214
214
  status: "ready",
215
215
  },
216
- ]);
216
+ ];
217
217
  }
218
- function workspaceAppFetchUrls(env) {
219
- const urls = [];
218
+ export function workspaceAppFetchUrls(env) {
219
+ const urls = [
220
+ "/_agent-native/actions/list-workspace-apps?includeAgentCards=false",
221
+ ];
220
222
  const gatewayUrl = envString(env, "VITE_WORKSPACE_GATEWAY_URL");
221
223
  if (gatewayUrl) {
222
224
  try {
223
- urls.push(new URL("/_workspace/apps", `${stripTrailingSlash(gatewayUrl)}/`).toString());
225
+ const gateway = new URL(gatewayUrl);
226
+ const localGateway = [
227
+ "localhost",
228
+ "127.0.0.1",
229
+ "0.0.0.0",
230
+ "::1",
231
+ ].includes(gateway.hostname.replace(/^\[|\]$/g, "").toLowerCase());
232
+ if (localGateway) {
233
+ urls.push(new URL("/_workspace/apps", `${stripTrailingSlash(gatewayUrl)}/`).toString());
234
+ }
224
235
  }
225
236
  catch {
226
- // Fall through to the same-origin Dispatch action below.
237
+ // coercion-ok: malformed gateway URL leaves the authenticated action as
238
+ // the only hosted source.
227
239
  }
228
240
  }
229
- urls.push("/_agent-native/actions/list-workspace-apps?includeAgentCards=false");
230
241
  return urls;
231
242
  }
232
243
  export function useOrgSwitcherAppLinks(enabled) {
@@ -62,11 +62,11 @@ export declare const postAwareness: import("h3").EventHandlerWithFetch<import("h
62
62
  error: string;
63
63
  states?: undefined;
64
64
  } | {
65
+ error?: undefined;
65
66
  states: {
66
67
  clientId: number;
67
68
  state: string;
68
69
  }[];
69
- error?: undefined;
70
70
  }>>;
71
71
  /**
72
72
  * GET /_agent-native/collab/:docId/users
@@ -77,9 +77,9 @@ export declare const getActiveUsers: import("h3").EventHandlerWithFetch<import("
77
77
  error: string;
78
78
  users?: undefined;
79
79
  } | {
80
+ error?: undefined;
80
81
  users: {
81
82
  clientId: number;
82
83
  lastSeen: number;
83
84
  }[];
84
- error?: undefined;
85
85
  }>>;
@@ -26,8 +26,8 @@ export declare const getCollabState: import("h3").EventHandlerWithFetch<import("
26
26
  * Body: { update: string (base64), requestSource?: string }
27
27
  */
28
28
  export declare const postCollabUpdate: import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
29
- ok?: undefined;
30
29
  error: string;
30
+ ok?: undefined;
31
31
  } | {
32
32
  error?: undefined;
33
33
  ok: boolean;
@@ -13,8 +13,8 @@
13
13
  * Body: { json: any, fieldName?: string, type?: "map"|"array", requestSource?: string }
14
14
  */
15
15
  export declare const postCollabJson: import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
16
- ok?: undefined;
17
16
  error: string;
17
+ ok?: undefined;
18
18
  } | {
19
19
  error?: undefined;
20
20
  ok: boolean;
@@ -41,16 +41,16 @@ export declare function createObservabilityHandler(): import("h3").EventHandlerW
41
41
  thumbsUpRate: number;
42
42
  avgEvalScore: number;
43
43
  } | {
44
+ error?: undefined;
44
45
  summary: import("./types.js").TraceSummary;
45
46
  spans: import("./types.js").TraceSpan[];
46
47
  id?: undefined;
47
- error?: undefined;
48
48
  ok?: undefined;
49
49
  } | {
50
+ error?: undefined;
50
51
  summary?: undefined;
51
52
  spans?: undefined;
52
53
  id: string;
53
- error?: undefined;
54
54
  ok?: undefined;
55
55
  } | {
56
56
  summary?: undefined;
@@ -59,9 +59,9 @@ export declare function createObservabilityHandler(): import("h3").EventHandlerW
59
59
  error: any;
60
60
  ok?: undefined;
61
61
  } | {
62
+ error?: undefined;
62
63
  summary?: undefined;
63
64
  spans?: undefined;
64
65
  id?: undefined;
65
- error?: undefined;
66
66
  ok: boolean;
67
67
  }>>;
@@ -37,17 +37,17 @@ export declare function createWriteSecretHandler(): import("h3").EventHandlerWit
37
37
  ok?: undefined;
38
38
  status?: undefined;
39
39
  } | {
40
+ error?: undefined;
40
41
  ok: boolean;
41
42
  status: string;
42
- error?: undefined;
43
43
  } | {
44
44
  ok?: undefined;
45
45
  error: string;
46
46
  removed?: undefined;
47
47
  } | {
48
+ error?: undefined;
48
49
  ok: boolean;
49
50
  removed: boolean;
50
- error?: undefined;
51
51
  }>>;
52
52
  /**
53
53
  * POST /_agent-native/secrets/:key/test — validate an optional candidate value
@@ -58,13 +58,13 @@ export declare function createTestSecretHandler(): import("h3").EventHandlerWith
58
58
  error: string;
59
59
  note?: undefined;
60
60
  } | {
61
+ error?: undefined;
61
62
  ok: boolean;
62
63
  note?: undefined;
63
- error?: undefined;
64
64
  } | {
65
+ error?: undefined;
65
66
  ok: boolean;
66
67
  note: string;
67
- error?: undefined;
68
68
  } | {
69
69
  note?: undefined;
70
70
  ok: boolean;
@@ -95,11 +95,11 @@ export interface AdHocSecretPayload {
95
95
  export declare function createAdHocSecretHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<AdHocSecretPayload[] | {
96
96
  error: string;
97
97
  } | {
98
+ error?: undefined;
98
99
  ok: boolean;
99
100
  key: string;
100
- error?: undefined;
101
101
  } | {
102
+ error?: undefined;
102
103
  ok: boolean;
103
104
  removed: boolean;
104
- error?: undefined;
105
105
  }>>;
@@ -35,6 +35,21 @@ export interface FirstTouchAttribution {
35
35
  /** ISO timestamp of when the visitor first landed. */
36
36
  landed_at?: string;
37
37
  }
38
+ /**
39
+ * Which flow created the account, as opposed to which credential it uses.
40
+ *
41
+ * Better Auth fires its `user.create.after` hook on every `user` row insert,
42
+ * but only some inserts are a person signing up in a browser. Every emitted
43
+ * `signup` event names its origin so a campaign report can select the ones
44
+ * that are acquisitions instead of counting row inserts.
45
+ */
46
+ export type SignupOrigin =
47
+ /** A person completed a signup form or magic link in a browser. */
48
+ "browser_signup"
49
+ /** The framework's Google OAuth callback owns this event. */
50
+ | "google_oauth"
51
+ /** Federated SSO provisioning an identity into a sibling app. */
52
+ | "sso_jit";
38
53
  /**
39
54
  * Request-scoped browser attribution captured at the signup boundary.
40
55
  * Better Auth may create the user in a later async callback where the
@@ -103,8 +118,15 @@ export declare function signupAttributionFromCookieHeader(cookieHeader: string |
103
118
  * Capture all browser attribution needed by the server-side signup event.
104
119
  * Keep this as one boundary helper so every signup entry point carries the
105
120
  * same values into Better Auth's user-create hook.
121
+ *
122
+ * Returns `undefined` when the request carried neither cookie. A browser that
123
+ * ran our client script always has `an_ft`, so "no cookies at all" means no
124
+ * browser — a server-side backfill or provisioning call. Reporting that as
125
+ * `referral_source: "direct"` is the coercion that made this metric unusable:
126
+ * it renders "we never saw a visitor" identical to "a visitor arrived with no
127
+ * campaign", and only the second one is direct traffic.
106
128
  */
107
- export declare function signupAttributionContextFromCookieHeader(cookieHeader: string | null | undefined): SignupAttributionContext;
129
+ export declare function signupAttributionContextFromCookieHeader(cookieHeader: string | null | undefined): SignupAttributionContext | undefined;
108
130
  /** Serialize the bounded attribution handoff for a Better Auth request. */
109
131
  export declare function encodeSignupAttributionContext(context: SignupAttributionContext): string;
110
132
  /**
@@ -112,7 +134,15 @@ export declare function encodeSignupAttributionContext(context: SignupAttributio
112
134
  * attribution, so the caller can safely fall back to the request cookie.
113
135
  */
114
136
  export declare function decodeSignupAttributionContext(value: string | null | undefined): SignupAttributionContext | undefined;
115
- /** Add the explicit handoff to a Better Auth request/API header set. */
116
- export declare function addSignupAttributionHeader(headers: HeadersInit | undefined, context: SignupAttributionContext): Headers;
137
+ /**
138
+ * Stamp the explicit handoff onto a Better Auth request/API header set.
139
+ *
140
+ * This is the only writer of the handoff header, and it always writes: with a
141
+ * context it sets ours, without one it deletes whatever was there. The header
142
+ * is unsigned and outranks the request cookie when the hook reads it, so an
143
+ * inbound copy from the public internet is an attacker-supplied `anonymous_id`
144
+ * and campaign for someone else's signup row. Never merge — replace.
145
+ */
146
+ export declare function addSignupAttributionHeader(headers: HeadersInit | undefined, context: SignupAttributionContext | undefined): Headers;
117
147
  /** Read the explicit handoff from Better Auth's request context headers. */
118
148
  export declare function signupAttributionContextFromHeaders(headers: Headers | null | undefined): SignupAttributionContext | undefined;
@@ -185,11 +185,21 @@ export function signupAttributionFromCookieHeader(cookieHeader) {
185
185
  * Capture all browser attribution needed by the server-side signup event.
186
186
  * Keep this as one boundary helper so every signup entry point carries the
187
187
  * same values into Better Auth's user-create hook.
188
+ *
189
+ * Returns `undefined` when the request carried neither cookie. A browser that
190
+ * ran our client script always has `an_ft`, so "no cookies at all" means no
191
+ * browser — a server-side backfill or provisioning call. Reporting that as
192
+ * `referral_source: "direct"` is the coercion that made this metric unusable:
193
+ * it renders "we never saw a visitor" identical to "a visitor arrived with no
194
+ * campaign", and only the second one is direct traffic.
188
195
  */
189
196
  export function signupAttributionContextFromCookieHeader(cookieHeader) {
197
+ const firstTouch = readFirstTouchAttribution(cookieHeader);
190
198
  const anonymousId = readAnalyticsAnonymousId(cookieHeader);
199
+ if (!firstTouch && !anonymousId)
200
+ return undefined;
191
201
  return {
192
- attribution: signupAttributionFromCookieHeader(cookieHeader),
202
+ attribution: deriveSignupAttribution(firstTouch),
193
203
  ...(anonymousId ? { anonymousId } : {}),
194
204
  };
195
205
  }
@@ -236,9 +246,21 @@ export function decodeSignupAttributionContext(value) {
236
246
  return undefined;
237
247
  }
238
248
  }
239
- /** Add the explicit handoff to a Better Auth request/API header set. */
249
+ /**
250
+ * Stamp the explicit handoff onto a Better Auth request/API header set.
251
+ *
252
+ * This is the only writer of the handoff header, and it always writes: with a
253
+ * context it sets ours, without one it deletes whatever was there. The header
254
+ * is unsigned and outranks the request cookie when the hook reads it, so an
255
+ * inbound copy from the public internet is an attacker-supplied `anonymous_id`
256
+ * and campaign for someone else's signup row. Never merge — replace.
257
+ */
240
258
  export function addSignupAttributionHeader(headers, context) {
241
259
  const result = new Headers(headers);
260
+ if (!context) {
261
+ result.delete(SIGNUP_ATTRIBUTION_HEADER_NAME);
262
+ return result;
263
+ }
242
264
  result.set(SIGNUP_ATTRIBUTION_HEADER_NAME, encodeSignupAttributionContext(context));
243
265
  return result;
244
266
  }
@@ -61,7 +61,7 @@ import { getAppProductionUrl } from "./app-url.js";
61
61
  import { addSignupAttributionHeader, readAnalyticsAnonymousId, readFirstTouchAttribution, signupAttributionContextFromCookieHeader, signupAttributionFromCookieHeader, } from "./attribution.js";
62
62
  import { getAuthLoginMode } from "./auth-login-mode.js";
63
63
  import { injectBetaOptOutPersistence } from "./beta-opt-out-html.js";
64
- import { createBetterAuthSessionForEmail, ensureGoogleAuthIdentity, getAuthSecret, getBetterAuth, getBetterAuthSync, isDeployPreview, } from "./better-auth-instance.js";
64
+ import { createBetterAuthSessionForEmail, ensureGoogleAuthIdentity, getBetterAuthUserIdForEmail, getAuthSecret, getBetterAuth, getBetterAuthSync, isDeployPreview, } from "./better-auth-instance.js";
65
65
  import { BUILDER_CONNECT_PARAM, BUILDER_RELAY_PATH, BUILDER_RELAY_STATE_PARAM, verifyBuilderConnectTokenAndGetOwner, verifyBuilderPreviewRelayStateForCallback, } from "./builder-browser.js";
66
66
  import { resolveAuthCookieNamespace } from "./cookie-namespace.js";
67
67
  import { getAllowedCorsOrigin, readCorsAllowedOrigins, } from "./cors-origins.js";
@@ -93,6 +93,13 @@ function withSignupAttributionContext(cookieHeader, fn) {
93
93
  signupAttribution,
94
94
  }, fn);
95
95
  }
96
+ /**
97
+ * Replace the internal attribution handoff on a request bound for Better
98
+ * Auth. Call this on every such request, including the ones with no
99
+ * attribution to add — the header is unsigned and outranks the request cookie
100
+ * inside the user-create hook, so an inbound copy is a stranger writing the
101
+ * `anonymous_id` and campaign of somebody else's signup.
102
+ */
96
103
  function requestWithSignupAttribution(request, signupAttribution) {
97
104
  return new Request(request, {
98
105
  headers: addSignupAttributionHeader(request.headers, signupAttribution),
@@ -3357,10 +3364,20 @@ async function mountBetterAuthRoutes(app, options) {
3357
3364
  mobile,
3358
3365
  trackSignup: {
3359
3366
  authProvider: "google",
3360
- authUserId: typeof user.id === "string" ? user.id : undefined,
3367
+ // The signup event joins to `referrer_user` in the virality
3368
+ // panels, which is always a Better Auth id — the Google profile
3369
+ // id that used to go here joined to nothing. Only looked up when
3370
+ // the event will actually be emitted.
3371
+ authUserId: isNewGoogleUser
3372
+ ? await getBetterAuthUserIdForEmail(email)
3373
+ : undefined,
3361
3374
  name: typeof user.name === "string" ? user.name : undefined,
3362
3375
  attribution: signupAttribution,
3363
3376
  signupAnonymousId,
3377
+ // `ensureGoogleAuthIdentity` above already wrote the canonical
3378
+ // row, so this callback is the only thing that still knows
3379
+ // whether the person is new.
3380
+ isNewUser: isNewGoogleUser,
3364
3381
  },
3365
3382
  });
3366
3383
  logGoogleOAuthDebug(event, "callback-session-created", {
@@ -3859,9 +3876,7 @@ async function mountBetterAuthRoutes(app, options) {
3859
3876
  // validation error.
3860
3877
  }
3861
3878
  }
3862
- if (signupAttribution) {
3863
- requestForAuth = requestWithSignupAttribution(requestForAuth, signupAttribution);
3864
- }
3879
+ requestForAuth = requestWithSignupAttribution(requestForAuth, signupAttribution);
3865
3880
  let response;
3866
3881
  try {
3867
3882
  response = await (isEmailSignup
@@ -6,12 +6,62 @@
6
6
  * implementation detail behind those interfaces.
7
7
  */
8
8
  import { type BetterAuthOptions } from "better-auth";
9
+ import { type SignupOrigin } from "./attribution.js";
9
10
  export { getAuthLoginMode, resolveAuthLoginMode, resolveAuthLoginModeFromReadiness, type AuthLoginMode, } from "./auth-login-mode.js";
10
11
  export declare function hasBetterAuthUserEmail(email: string): Promise<boolean>;
12
+ /**
13
+ * The canonical Better Auth user id for an email, or `undefined` when there
14
+ * is no row (or the adapter is unavailable).
15
+ *
16
+ * Signup events must carry this id, not the identity provider's subject id:
17
+ * the virality panels self-join `auth_user_id` against `referrer_user`, and
18
+ * `referrer_user` is always a Better Auth id, so a Google profile id in that
19
+ * column silently joins to nothing.
20
+ */
21
+ export declare function getBetterAuthUserIdForEmail(email: string): Promise<string | undefined>;
22
+ /**
23
+ * The endpoint context Better Auth (1.6.x) hands to `user.create.after`. It is
24
+ * `null` for any row created through `internalAdapter` outside an endpoint —
25
+ * see `emitSignupEventForCreatedUser`.
26
+ */
27
+ export interface BetterAuthUserCreateContext {
28
+ headers?: Headers | null;
29
+ request?: {
30
+ headers?: Headers | null;
31
+ url?: string;
32
+ } | null;
33
+ }
34
+ /**
35
+ * Emit the `signup` event for a freshly created Better Auth `user` row — but
36
+ * only when that row is an actual person signing up.
37
+ *
38
+ * Better Auth runs its create hook on every `user` insert. Only inserts made
39
+ * through one of its HTTP endpoints carry the originating request; everything
40
+ * that reaches the row through `internalAdapter` directly gets a null context,
41
+ * because `getCurrentAuthContext()` throws outside an endpoint. Two production
42
+ * paths do exactly that, and neither is an acquisition:
43
+ *
44
+ * - `ensureCanonicalUserForLegacySession` backfills a canonical row for
45
+ * someone who signed up long ago, on an ordinary authenticated request.
46
+ * - `ensureGoogleAuthIdentity` provisions the canonical row during the Google
47
+ * callback, whose own attributed event `createOAuthSession` emits.
48
+ *
49
+ * Emitting for those is what broke campaign reporting: they arrived with no
50
+ * browser context and were recorded as `referral_source: "direct"`, so ~94% of
51
+ * `better-auth` signups were unattributable rows that were never signups, and
52
+ * one person provisioned across sibling apps counted as a dozen. A row insert
53
+ * is not an acquisition, so a context-free insert emits nothing at all.
54
+ */
55
+ export declare function emitSignupEventForCreatedUser(user: {
56
+ id?: string;
57
+ email?: string;
58
+ name?: string | null;
59
+ }, context?: BetterAuthUserCreateContext | null): Promise<void>;
11
60
  /** Return whether the canonical user has a verified Google account link. */
12
61
  export declare function hasGoogleAuthIdentity(email: string): Promise<boolean | undefined>;
13
- export declare function trackSignupEvent({ authProvider, authUserId, email, name, attribution, anonymousId, }: {
62
+ export declare function trackSignupEvent({ authProvider, origin, authUserId, email, name, attribution, anonymousId, }: {
14
63
  authProvider: string;
64
+ origin: SignupOrigin;
15
65
  authUserId?: string;
16
66
  email: string;
17
67
  name?: string | null;
@@ -20,7 +70,9 @@ export declare function trackSignupEvent({ authProvider, authUserId, email, name
20
70
  * cookie (see `server/attribution.ts`). Snake_case keys such as
21
71
  * `referral_source`, `referrer_user`, and the UTM passthrough are merged
22
72
  * into the `signup` event so we can measure where new users came from.
23
- * `undefined` values are dropped; a missing object is a clean no-op.
73
+ * `undefined` values are dropped; a missing object is a clean no-op — and
74
+ * it must stay a no-op rather than defaulting to `direct`, so a signup we
75
+ * could not attribute stays visibly different from an unattributed visit.
24
76
  */
25
77
  attribution?: Record<string, string | undefined>;
26
78
  anonymousId?: string;
@@ -56,6 +56,95 @@ export async function hasBetterAuthUserEmail(email) {
56
56
  .catch(() => null);
57
57
  return !!existing?.user?.email;
58
58
  }
59
+ /**
60
+ * The canonical Better Auth user id for an email, or `undefined` when there
61
+ * is no row (or the adapter is unavailable).
62
+ *
63
+ * Signup events must carry this id, not the identity provider's subject id:
64
+ * the virality panels self-join `auth_user_id` against `referrer_user`, and
65
+ * `referrer_user` is always a Better Auth id, so a Google profile id in that
66
+ * column silently joins to nothing.
67
+ */
68
+ export async function getBetterAuthUserIdForEmail(email) {
69
+ const adapter = await getBetterAuthInternalAdapter();
70
+ if (!adapter)
71
+ return undefined;
72
+ try {
73
+ const existing = await adapter.findUserByEmail(email.trim().toLowerCase(), {
74
+ includeAccounts: false,
75
+ });
76
+ return existing?.user?.id || undefined;
77
+ }
78
+ catch (error) {
79
+ // coercion-ok: this is analytics enrichment on the sign-in path. Failing
80
+ // the sign-in over it would be worse, and the only consequence is one
81
+ // event missing `auth_user_id` — never a wrong id. Logged, not swallowed.
82
+ console.error("[auth] failed to resolve the canonical user id for a signup event", error);
83
+ return undefined;
84
+ }
85
+ }
86
+ /**
87
+ * Emit the `signup` event for a freshly created Better Auth `user` row — but
88
+ * only when that row is an actual person signing up.
89
+ *
90
+ * Better Auth runs its create hook on every `user` insert. Only inserts made
91
+ * through one of its HTTP endpoints carry the originating request; everything
92
+ * that reaches the row through `internalAdapter` directly gets a null context,
93
+ * because `getCurrentAuthContext()` throws outside an endpoint. Two production
94
+ * paths do exactly that, and neither is an acquisition:
95
+ *
96
+ * - `ensureCanonicalUserForLegacySession` backfills a canonical row for
97
+ * someone who signed up long ago, on an ordinary authenticated request.
98
+ * - `ensureGoogleAuthIdentity` provisions the canonical row during the Google
99
+ * callback, whose own attributed event `createOAuthSession` emits.
100
+ *
101
+ * Emitting for those is what broke campaign reporting: they arrived with no
102
+ * browser context and were recorded as `referral_source: "direct"`, so ~94% of
103
+ * `better-auth` signups were unattributable rows that were never signups, and
104
+ * one person provisioned across sibling apps counted as a dozen. A row insert
105
+ * is not an acquisition, so a context-free insert emits nothing at all.
106
+ */
107
+ export async function emitSignupEventForCreatedUser(user, context) {
108
+ const email = user?.email;
109
+ if (!email)
110
+ return;
111
+ const requestHeaders = context?.headers ?? context?.request?.headers ?? null;
112
+ if (!requestHeaders)
113
+ return;
114
+ const scoped = hasContinuationLocalRequestContext()
115
+ ? getRequestContext()
116
+ : undefined;
117
+ let attribution;
118
+ let anonymousId;
119
+ try {
120
+ const browser =
121
+ // The signed magic-link token is the only source that survives the link
122
+ // being opened in a different browser than the one that requested it.
123
+ (context?.request?.url?.includes("newUserCallbackURL")
124
+ ? readMagicLinkSignupAttribution(context.request.url, getAuthSecret())
125
+ : undefined) ??
126
+ scoped?.signupAttribution ??
127
+ signupAttributionContextFromHeaders(requestHeaders) ??
128
+ signupAttributionContextFromCookieHeader(requestHeaders.get("cookie"));
129
+ attribution = browser?.attribution;
130
+ anonymousId = browser?.anonymousId;
131
+ }
132
+ catch (err) {
133
+ // Analytics must never block signup, but a parse failure is not "this
134
+ // visitor came direct" — leave both unset so the event shows the gap
135
+ // instead of inventing a source for it.
136
+ console.error("[auth] failed to derive signup attribution", err);
137
+ }
138
+ await trackSignupEvent({
139
+ authProvider: "better-auth",
140
+ origin: scoped?.signupOrigin ?? "browser_signup",
141
+ authUserId: user.id,
142
+ email,
143
+ name: user.name,
144
+ attribution,
145
+ anonymousId,
146
+ });
147
+ }
59
148
  /** Return whether the canonical user has a verified Google account link. */
60
149
  export async function hasGoogleAuthIdentity(email) {
61
150
  const adapter = await getBetterAuthInternalAdapter();
@@ -67,7 +156,7 @@ export async function hasGoogleAuthIdentity(email) {
67
156
  return (existing?.accounts.some((account) => account.providerId === "google") ??
68
157
  false);
69
158
  }
70
- export async function trackSignupEvent({ authProvider, authUserId, email, name, attribution, anonymousId, }) {
159
+ export async function trackSignupEvent({ authProvider, origin, authUserId, email, name, attribution, anonymousId, }) {
71
160
  identify(email, {
72
161
  email,
73
162
  name: name ?? undefined,
@@ -84,6 +173,7 @@ export async function trackSignupEvent({ authProvider, authUserId, email, name,
84
173
  track("signup", {
85
174
  ...resolveSignupTrackingProperties(),
86
175
  auth_provider: authProvider,
176
+ signup_origin: origin,
87
177
  ...(authUserId ? { auth_user_id: authUserId } : {}),
88
178
  ...cleanAttribution,
89
179
  }, { userId: email, ...(anonymousId ? { anonymousId } : {}) });
@@ -1064,54 +1154,12 @@ async function createBetterAuthInstance(config) {
1064
1154
  // signups the callback request's headers), which is where the
1065
1155
  // browser's `an_ft` first-touch cookie rides in.
1066
1156
  context) => {
1067
- // When a newly-created user's email has pending org invitations
1068
- // (common when someone is invited *before* they've signed up),
1069
- // auto-accept them so the user lands in the org on their very
1070
- // first page load instead of a blank-slate workspace.
1071
1157
  const email = user?.email;
1072
1158
  if (!email)
1073
1159
  return;
1074
- // Derive first-touch referral attribution from the request's
1075
- // cookie header. Never let attribution parsing throw or block
1076
- // signup — on any error fall back to `direct`.
1077
- let attribution;
1078
- let anonymousId;
1079
- try {
1080
- const cookieHeader = context?.headers?.get("cookie") ??
1081
- context?.request?.headers?.get("cookie") ??
1082
- null;
1083
- const scopedSignupAttribution = hasContinuationLocalRequestContext()
1084
- ? getRequestContext()?.signupAttribution
1085
- : undefined;
1086
- const requestSignupAttribution = signupAttributionContextFromCookieHeader(cookieHeader);
1087
- const headerSignupAttribution = signupAttributionContextFromHeaders(context?.headers) ??
1088
- signupAttributionContextFromHeaders(context?.request?.headers);
1089
- const magicLinkAttribution = context?.request?.url?.includes("newUserCallbackURL")
1090
- ? readMagicLinkSignupAttribution(context.request.url, getAuthSecret())
1091
- : undefined;
1092
- attribution =
1093
- magicLinkAttribution?.attribution ??
1094
- scopedSignupAttribution?.attribution ??
1095
- headerSignupAttribution?.attribution ??
1096
- requestSignupAttribution.attribution;
1097
- anonymousId =
1098
- magicLinkAttribution?.anonymousId ??
1099
- scopedSignupAttribution?.anonymousId ??
1100
- headerSignupAttribution?.anonymousId ??
1101
- requestSignupAttribution.anonymousId;
1102
- }
1103
- catch (err) {
1104
- console.error("[auth] failed to derive signup attribution", err);
1105
- attribution = undefined;
1106
- }
1107
- await trackSignupEvent({
1108
- authProvider: "better-auth",
1109
- authUserId: user.id,
1110
- email,
1111
- name: user.name,
1112
- attribution,
1113
- anonymousId,
1114
- });
1160
+ await emitSignupEventForCreatedUser(user, context);
1161
+ // Invitations and domain auto-join are about the row existing, not
1162
+ // about how it got here, so they run for backfills too.
1115
1163
  try {
1116
1164
  await acceptPendingInvitationsForEmail(email);
1117
1165
  }
@@ -192,6 +192,15 @@ export declare function createOAuthSession(event: H3Event, email: string, opts:
192
192
  name?: string | null;
193
193
  attribution?: Record<string, string | undefined>;
194
194
  signupAnonymousId?: string;
195
+ /**
196
+ * Whether this callback created the account, decided by the caller at
197
+ * the moment it created it. Callers that provision the canonical user
198
+ * before getting here MUST pass this: the `hasBetterAuthUserEmail`
199
+ * probe below then reads the row they just wrote and concludes the
200
+ * person is an existing user, which silently deleted the only
201
+ * attributed signup event Google sign-in produces.
202
+ */
203
+ isNewUser?: boolean;
195
204
  };
196
205
  }): Promise<OAuthSessionResult>;
197
206
  /**
@@ -562,11 +562,12 @@ export async function createOAuthSession(event, email, opts) {
562
562
  let shouldTrackSignup = false;
563
563
  if (!opts.hasProductionSession || needsDeepLink) {
564
564
  if (opts.trackSignup && !opts.hasProductionSession) {
565
- const [hasLegacySession, hasBetterAuthUser] = await Promise.all([
566
- hasLegacySessionForEmail(email).catch(() => true),
567
- hasBetterAuthUserEmail(email).catch(() => true),
568
- ]);
569
- shouldTrackSignup = !hasLegacySession && !hasBetterAuthUser;
565
+ shouldTrackSignup =
566
+ opts.trackSignup.isNewUser ??
567
+ (await Promise.all([
568
+ hasLegacySessionForEmail(email).catch(() => true),
569
+ hasBetterAuthUserEmail(email).catch(() => true),
570
+ ]).then(([hasLegacySession, hasUser]) => !hasLegacySession && !hasUser));
570
571
  }
571
572
  sessionToken = crypto.randomBytes(32).toString("hex");
572
573
  await addSession(sessionToken, email);
@@ -578,6 +579,7 @@ export async function createOAuthSession(event, email, opts) {
578
579
  readAnalyticsAnonymousId(getHeader(event, "cookie") ?? null);
579
580
  await trackSignupEvent({
580
581
  authProvider: opts.trackSignup.authProvider,
582
+ origin: "google_oauth",
581
583
  authUserId: opts.trackSignup.authUserId,
582
584
  email,
583
585
  name: opts.trackSignup.name,
@@ -474,6 +474,10 @@ export async function handleIdentitySso(event, subpath) {
474
474
  ? runWithRequestContext({
475
475
  ...(getRequestContext() ?? {}),
476
476
  signupAttribution,
477
+ // This person already signed up somewhere; we are provisioning
478
+ // them into this app. Counting it as an acquisition is how one
479
+ // human became a dozen "signups" across sibling apps.
480
+ signupOrigin: "sso_jit",
477
481
  }, linkIdentity)
478
482
  : linkIdentity());
479
483
  }
@@ -26,8 +26,8 @@ export declare function createRealtimeTokenHandler(): import("h3").EventHandlerW
26
26
  expiresAt?: undefined;
27
27
  ttlSeconds?: undefined;
28
28
  } | {
29
+ error?: undefined;
29
30
  token: string;
30
31
  expiresAt: string;
31
32
  ttlSeconds: number;
32
- error?: undefined;
33
33
  }>>;
@@ -115,6 +115,12 @@ export interface RequestContext {
115
115
  * async user-create hook. Analytics-only; never used for authorization.
116
116
  */
117
117
  signupAttribution?: SignupAttributionContext;
118
+ /**
119
+ * Which flow is creating a user row on this request. Set by callers that
120
+ * provision an identity rather than acquire a new person, so the signup
121
+ * event they trigger says so instead of impersonating a browser signup.
122
+ */
123
+ signupOrigin?: import("./attribution.js").SignupOrigin;
118
124
  /** Canonical client surface for analytics attribution. */
119
125
  clientPlatform?: import("../shared/analytics-platform.js").AnalyticsClientPlatform;
120
126
  /**
@@ -20,6 +20,6 @@ export declare function createTranscribeVoiceHandler(): import("h3").EventHandle
20
20
  error: string;
21
21
  text?: undefined;
22
22
  } | {
23
- text: string;
24
23
  error?: undefined;
24
+ text: string;
25
25
  }>>;
@@ -324,6 +324,9 @@ function createWebhookProvider(url, authHeader) {
324
324
  event: event.name,
325
325
  properties: event.properties,
326
326
  userId: event.userId,
327
+ // Without this a webhook consumer cannot join a signup back to the
328
+ // anonymous pageviews that preceded it — the whole point of the id.
329
+ anonymousId: event.anonymousId,
327
330
  sessionId: event.sessionId,
328
331
  timestamp: event.timestamp,
329
332
  }), extra);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.172.6",
3
+ "version": "0.172.9",
4
4
  "description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {