@agent-native/core 0.144.3 → 0.145.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 (71) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/clips/changelog/2026-08-06-desktop-full-screen-recordings-now-normalize-microphone-leve.md +6 -0
  3. package/corpus/templates/clips/desktop/src-tauri/src/native_screen/custom_capture.rs +185 -69
  4. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +47 -4
  5. package/corpus/templates/forms/server/plugins/agent-chat.ts +1 -1
  6. package/corpus/templates/slides/server/plugins/agent-chat.ts +1 -1
  7. package/dist/agent/production-agent.d.ts +6 -0
  8. package/dist/agent/run-manager.d.ts +44 -1
  9. package/dist/agent/run-manager.js +70 -8
  10. package/dist/chat-threads/store.js +6 -4
  11. package/dist/client/use-db-sync.js +9 -1
  12. package/dist/collab/awareness.d.ts +2 -2
  13. package/dist/collab/struct-routes.d.ts +1 -1
  14. package/dist/db/ddl-guard.d.ts +8 -0
  15. package/dist/db/ddl-guard.js +148 -0
  16. package/dist/deploy/build.d.ts +36 -0
  17. package/dist/deploy/build.js +78 -5
  18. package/dist/framework-tools.d.ts +126 -0
  19. package/dist/framework-tools.js +114 -0
  20. package/dist/notifications/routes.d.ts +3 -3
  21. package/dist/org/accept-pending.js +2 -2
  22. package/dist/org/auth-policy.js +4 -0
  23. package/dist/org/auto-join-domain.d.ts +9 -0
  24. package/dist/org/auto-join-domain.js +53 -4
  25. package/dist/org/context.js +12 -3
  26. package/dist/org/handlers.js +17 -5
  27. package/dist/org/request-org-cache.d.ts +24 -2
  28. package/dist/org/request-org-cache.js +57 -2
  29. package/dist/provider-api/actions/custom-provider-registration.d.ts +6 -6
  30. package/dist/provider-api/actions/provider-api.d.ts +6 -6
  31. package/dist/resources/store.d.ts +1 -1
  32. package/dist/resources/store.js +96 -7
  33. package/dist/secrets/routes.d.ts +9 -9
  34. package/dist/server/action-discovery.d.ts +27 -0
  35. package/dist/server/action-discovery.js +89 -0
  36. package/dist/server/agent-chat/action-filters-a2a.d.ts +11 -0
  37. package/dist/server/agent-chat/action-filters-a2a.js +17 -1
  38. package/dist/server/agent-chat/framework-prompts.d.ts +2 -0
  39. package/dist/server/agent-chat/plugin-options.d.ts +30 -0
  40. package/dist/server/agent-chat/prompt-resources.d.ts +4 -1
  41. package/dist/server/agent-chat/prompt-resources.js +8 -2
  42. package/dist/server/agent-chat-plugin.js +138 -75
  43. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  44. package/dist/server/auth.d.ts +3 -0
  45. package/dist/server/auth.js +22 -1
  46. package/dist/server/collab-plugin.js +10 -4
  47. package/dist/server/core-routes-plugin.js +3 -0
  48. package/dist/server/prompts/framework-core-compact.d.ts +5 -0
  49. package/dist/server/prompts/framework-core-compact.js +28 -12
  50. package/dist/server/prompts/framework-core.d.ts +5 -0
  51. package/dist/server/prompts/framework-core.js +33 -16
  52. package/dist/server/prompts/index.d.ts +1 -1
  53. package/dist/server/prompts/index.js +1 -1
  54. package/dist/server/prompts/shared-rules.d.ts +12 -4
  55. package/dist/server/prompts/shared-rules.js +14 -4
  56. package/dist/server/session-email-cache.d.ts +14 -0
  57. package/dist/server/session-email-cache.js +48 -0
  58. package/dist/server/sweep-runtime.d.ts +22 -0
  59. package/dist/server/sweep-runtime.js +28 -0
  60. package/dist/settings/store.js +63 -7
  61. package/dist/shared/ttl-cache.d.ts +59 -0
  62. package/dist/shared/ttl-cache.js +43 -0
  63. package/dist/sharing/actions/list-resource-shares.d.ts +3 -3
  64. package/dist/sharing/actions/list-resource-shares.js +4 -1
  65. package/dist/templates/workspace-core/.agents/skills/extensions/SKILL.md +6 -3
  66. package/dist/triggers/condition-evaluator.js +11 -27
  67. package/docs/content/deployment.mdx +95 -13
  68. package/docs/content/environment-variables.mdx +5 -0
  69. package/docs/content/key-concepts.mdx +13 -6
  70. package/package.json +1 -1
  71. package/src/templates/workspace-core/.agents/skills/extensions/SKILL.md +6 -3
@@ -0,0 +1,126 @@
1
+ import { type DatabaseToolsOption } from "./scripts/db/tool-mode.js";
2
+ /**
3
+ * Optional framework tool groups an app can turn off through `frameworkTools`.
4
+ *
5
+ * Each name is also stamped onto the group's actions as
6
+ * `ActionEntry.frameworkGroup`. That tag is what lets a single filter subtract a
7
+ * group from every agent tool surface at once: `agent-chat-plugin.ts` spreads
8
+ * these registries at thirteen separate composition sites (interactive chat,
9
+ * dev, MCP-full, A2A, background jobs, triggers), and a per-site condition would
10
+ * be the same omission waiting to happen thirteen times.
11
+ *
12
+ * The tag has a second job: framework-tagged actions are excluded from the
13
+ * DEFAULT first-request tool list, so an app pays for the ~40 kit schemas only
14
+ * when it names them in `initialToolNames`. They stay in the searchable registry
15
+ * either way.
16
+ */
17
+ export declare const FRAMEWORK_TOOL_GROUPS: readonly ["sharing", "review", "history", "featureFlags", "localization", "audit", "contextXray", "userProfile", "automation", "docs", "resources", "web", "workspaceApps", "chat", "email"];
18
+ export type FrameworkToolGroup = (typeof FRAMEWORK_TOOL_GROUPS)[number];
19
+ /**
20
+ * Per-group switches for the framework's own agent tools. Every group defaults
21
+ * to today's behavior, so omitting this option leaves the tool surface
22
+ * byte-identical.
23
+ *
24
+ * Turning a group off removes it from the agent surfaces only — interactive
25
+ * chat, MCP, A2A, and background runs. The matching HTTP action routes stay
26
+ * mounted (see `httpActions` in `agent-chat-plugin.ts`) because the UI reaches
27
+ * them through client hooks; a flag meant to trim the model's tool list must not
28
+ * 404 the share dialog.
29
+ *
30
+ * Note the parity cost before disabling one: the framework's contract is that
31
+ * anything the UI can do, the agent can do. `sharing: false` on an app that
32
+ * still renders `ShareButton` breaks that on purpose, and only makes sense when
33
+ * the app has no such surface at all.
34
+ */
35
+ export interface FrameworkToolsOption {
36
+ /** Raw SQL tools. `"read"` (default) exposes `db-schema`/`db-query`;
37
+ * `"write"` adds `db-exec`/`db-patch`; `"off"` exposes neither. */
38
+ database?: DatabaseToolsOption;
39
+ /** Extension management (`create-extension`, `update-extension`, …).
40
+ * Defaults to false — only apps that intentionally let the agent build
41
+ * sandboxed mini-apps should enable it. */
42
+ extensions?: boolean;
43
+ /** `share-resource`, `unshare-resource`, `list-resource-shares`,
44
+ * `set-resource-visibility`, `create-agent-resource-link`. */
45
+ sharing?: boolean;
46
+ /** The inline review/comment kit (`list-review-comments`,
47
+ * `create-review-comment`, `resolve-review-thread`, …). */
48
+ review?: boolean;
49
+ /** Version snapshots and restore (`create-resource-version`,
50
+ * `list-resource-versions`, `restore-resource-version`, …). */
51
+ history?: boolean;
52
+ /** `get-feature-flags`, `list-feature-flags`, `set-feature-flag`. */
53
+ featureFlags?: boolean;
54
+ /** `get-localization-preference`, `set-localization-preference`. */
55
+ localization?: boolean;
56
+ /** `list-audit-events`, `get-audit-event`, `export-audit-events`. */
57
+ audit?: boolean;
58
+ /** Context X-Ray (`context-manifest-get`, `context-pin`, `context-evict`, …). */
59
+ contextXray?: boolean;
60
+ /** `get-user-profile`, `update-user-profile`, `change-appearance`. */
61
+ userProfile?: boolean;
62
+ /** Recurring jobs, automations, notifications, and progress runs. */
63
+ automation?: boolean;
64
+ /** `docs-search` over the bundled framework documentation. */
65
+ docs?: boolean;
66
+ /** The `resources` tool — workspace notes, memory, and context files. */
67
+ resources?: boolean;
68
+ /** `web-request` and `web-search`. */
69
+ web?: boolean;
70
+ /** `describe-workspace-apps` and `call-agent` for cross-app delegation. */
71
+ workspaceApps?: boolean;
72
+ /** `chat-history`, `manage-agent-engine`, `manage-agent-loop-settings`. */
73
+ chat?: boolean;
74
+ /** `core-send-email`. */
75
+ email?: boolean;
76
+ /** `"minimal"` turns every group above off, for voice-first and
77
+ * single-purpose apps that want the template's own actions and nothing else.
78
+ * Any explicit group key wins over the preset, so
79
+ * `{ preset: "minimal", resources: true }` keeps exactly one group. */
80
+ preset?: "minimal";
81
+ }
82
+ export type FrameworkToolsConfig = FrameworkToolsOption | "minimal";
83
+ export interface FrameworkToolsInput {
84
+ frameworkTools?: FrameworkToolsConfig;
85
+ /** @deprecated Use `frameworkTools.database`. */
86
+ databaseTools?: DatabaseToolsOption;
87
+ /** @deprecated Use `frameworkTools.extensions`. */
88
+ extensionTools?: boolean;
89
+ }
90
+ export interface ResolvedFrameworkTools {
91
+ /** Pass-through for `normalizeDatabaseToolsMode`; `undefined` means default. */
92
+ database: DatabaseToolsOption | undefined;
93
+ extensions: boolean;
94
+ disabledGroups: ReadonlySet<FrameworkToolGroup>;
95
+ isEnabled(group: FrameworkToolGroup): boolean;
96
+ }
97
+ /**
98
+ * Resolve the public option surface into one shape the plugin threads inward.
99
+ *
100
+ * Legacy `databaseTools` / `extensionTools` remain accepted for one minor. When
101
+ * both forms are present and disagree we throw at plugin init rather than
102
+ * picking a winner: an app that boots with a tool surface nobody chose is how a
103
+ * "why can't the agent see db-query" report ends up unexplainable.
104
+ */
105
+ export declare function resolveFrameworkTools(input: FrameworkToolsInput | undefined): ResolvedFrameworkTools;
106
+ /** Structural view of the one field these helpers read, so tagging utilities
107
+ * stay free of an import cycle with `ActionEntry`. */
108
+ interface FrameworkGrouped {
109
+ frameworkGroup?: FrameworkToolGroup;
110
+ }
111
+ /**
112
+ * Drop every action belonging to a disabled group. Returns the input untouched
113
+ * when nothing is disabled so the default path allocates nothing.
114
+ */
115
+ export declare function filterFrameworkToolGroups<T extends FrameworkGrouped>(actions: Record<string, T>, disabledGroups: ReadonlySet<FrameworkToolGroup>): Record<string, T>;
116
+ /** True for actions the framework contributes rather than the app's own. */
117
+ export declare function isFrameworkGroupedAction(entry: FrameworkGrouped): boolean;
118
+ /**
119
+ * Whether a group is live, for prompt builders that only receive the disabled
120
+ * set. Prompt text and tool schemas must agree: a prompt that names a tool the
121
+ * request does not carry makes the model try it, fail, and often report the
122
+ * capability as nonexistent — so every block naming a group's tool by name has
123
+ * to be gated on the same set that gates the registry.
124
+ */
125
+ export declare function frameworkGroupEnabled(disabledGroups: ReadonlySet<FrameworkToolGroup> | undefined, group: FrameworkToolGroup): boolean;
126
+ export {};
@@ -0,0 +1,114 @@
1
+ import { normalizeDatabaseToolsMode, } from "./scripts/db/tool-mode.js";
2
+ /**
3
+ * Optional framework tool groups an app can turn off through `frameworkTools`.
4
+ *
5
+ * Each name is also stamped onto the group's actions as
6
+ * `ActionEntry.frameworkGroup`. That tag is what lets a single filter subtract a
7
+ * group from every agent tool surface at once: `agent-chat-plugin.ts` spreads
8
+ * these registries at thirteen separate composition sites (interactive chat,
9
+ * dev, MCP-full, A2A, background jobs, triggers), and a per-site condition would
10
+ * be the same omission waiting to happen thirteen times.
11
+ *
12
+ * The tag has a second job: framework-tagged actions are excluded from the
13
+ * DEFAULT first-request tool list, so an app pays for the ~40 kit schemas only
14
+ * when it names them in `initialToolNames`. They stay in the searchable registry
15
+ * either way.
16
+ */
17
+ export const FRAMEWORK_TOOL_GROUPS = [
18
+ "sharing",
19
+ "review",
20
+ "history",
21
+ "featureFlags",
22
+ "localization",
23
+ "audit",
24
+ "contextXray",
25
+ "userProfile",
26
+ "automation",
27
+ "docs",
28
+ "resources",
29
+ "web",
30
+ "workspaceApps",
31
+ "chat",
32
+ "email",
33
+ ];
34
+ function normalizeFrameworkToolsConfig(config) {
35
+ if (config === "minimal")
36
+ return { option: {}, minimal: true };
37
+ if (!config || typeof config !== "object") {
38
+ return { option: {}, minimal: false };
39
+ }
40
+ const { preset, ...option } = config;
41
+ return { option, minimal: preset === "minimal" };
42
+ }
43
+ function conflict(key, legacyKey, legacyValue, nestedValue) {
44
+ throw new Error(`[agent-native] Conflicting agent-chat options: \`${legacyKey}: ${JSON.stringify(legacyValue)}\` ` +
45
+ `and \`frameworkTools.${key}: ${JSON.stringify(nestedValue)}\` disagree. ` +
46
+ `Remove the deprecated \`${legacyKey}\` and keep \`frameworkTools.${key}\`.`);
47
+ }
48
+ /**
49
+ * Resolve the public option surface into one shape the plugin threads inward.
50
+ *
51
+ * Legacy `databaseTools` / `extensionTools` remain accepted for one minor. When
52
+ * both forms are present and disagree we throw at plugin init rather than
53
+ * picking a winner: an app that boots with a tool surface nobody chose is how a
54
+ * "why can't the agent see db-query" report ends up unexplainable.
55
+ */
56
+ export function resolveFrameworkTools(input) {
57
+ const { option, minimal } = normalizeFrameworkToolsConfig(input?.frameworkTools);
58
+ const legacyDatabase = input?.databaseTools;
59
+ if (legacyDatabase !== undefined) {
60
+ if (option.database !== undefined &&
61
+ normalizeDatabaseToolsMode(legacyDatabase) !==
62
+ normalizeDatabaseToolsMode(option.database)) {
63
+ conflict("database", "databaseTools", legacyDatabase, option.database);
64
+ }
65
+ console.warn("[agent-native] `databaseTools` is deprecated — use `frameworkTools: { database: … }`.");
66
+ }
67
+ const legacyExtensions = input?.extensionTools;
68
+ if (legacyExtensions !== undefined) {
69
+ if (option.extensions !== undefined &&
70
+ (legacyExtensions === true) !== (option.extensions === true)) {
71
+ conflict("extensions", "extensionTools", legacyExtensions, option.extensions);
72
+ }
73
+ console.warn("[agent-native] `extensionTools` is deprecated — use `frameworkTools: { extensions: … }`.");
74
+ }
75
+ const database = option.database ?? legacyDatabase ?? (minimal ? "off" : undefined);
76
+ const extensions = option.extensions ?? legacyExtensions ?? false;
77
+ const disabledGroups = new Set();
78
+ for (const group of FRAMEWORK_TOOL_GROUPS) {
79
+ const explicit = option[group];
80
+ if (explicit === false || (explicit === undefined && minimal)) {
81
+ disabledGroups.add(group);
82
+ }
83
+ }
84
+ return {
85
+ database,
86
+ extensions,
87
+ disabledGroups,
88
+ isEnabled: (group) => !disabledGroups.has(group),
89
+ };
90
+ }
91
+ /**
92
+ * Drop every action belonging to a disabled group. Returns the input untouched
93
+ * when nothing is disabled so the default path allocates nothing.
94
+ */
95
+ export function filterFrameworkToolGroups(actions, disabledGroups) {
96
+ if (disabledGroups.size === 0)
97
+ return actions;
98
+ return Object.fromEntries(Object.entries(actions).filter(([, entry]) => entry.frameworkGroup === undefined ||
99
+ !disabledGroups.has(entry.frameworkGroup)));
100
+ }
101
+ /** True for actions the framework contributes rather than the app's own. */
102
+ export function isFrameworkGroupedAction(entry) {
103
+ return entry.frameworkGroup !== undefined;
104
+ }
105
+ /**
106
+ * Whether a group is live, for prompt builders that only receive the disabled
107
+ * set. Prompt text and tool schemas must agree: a prompt that names a tool the
108
+ * request does not carry makes the model try it, fail, and often report the
109
+ * capability as nonexistent — so every block naming a group's tool by name has
110
+ * to be gated on the same set that gates the registry.
111
+ */
112
+ export function frameworkGroupEnabled(disabledGroups, group) {
113
+ return !disabledGroups?.has(group);
114
+ }
@@ -16,18 +16,18 @@ export declare function createNotificationsHandler(): import("h3").EventHandlerW
16
16
  error?: undefined;
17
17
  ok?: undefined;
18
18
  } | {
19
+ count?: undefined;
19
20
  updated: number;
20
21
  error?: undefined;
21
22
  ok?: undefined;
22
- count?: undefined;
23
23
  } | {
24
+ count?: undefined;
24
25
  updated?: undefined;
25
26
  error: string;
26
27
  ok?: undefined;
27
- count?: undefined;
28
28
  } | {
29
+ count?: undefined;
29
30
  updated?: undefined;
30
31
  ok: boolean;
31
32
  error?: undefined;
32
- count?: undefined;
33
33
  }>>;
@@ -1,6 +1,6 @@
1
1
  import { getDbExec } from "../db/client.js";
2
2
  import { setActiveOrgId } from "./active-org.js";
3
- import { invalidateRequestMemberOrgIds } from "./request-org-cache.js";
3
+ import { invalidateMemberOrgCaches } from "./request-org-cache.js";
4
4
  const nanoid = () => globalThis.crypto?.randomUUID?.().replace(/-/g, "") ??
5
5
  Math.random().toString(36).slice(2) + Date.now().toString(36);
6
6
  /**
@@ -63,7 +63,7 @@ export async function acceptPendingInvitationsForEmail(rawEmail) {
63
63
  ON CONFLICT (org_id, LOWER(email)) DO NOTHING`,
64
64
  args: [nanoid(), inv.orgId, email, role, Date.now()],
65
65
  });
66
- invalidateRequestMemberOrgIds();
66
+ invalidateMemberOrgCaches();
67
67
  }
68
68
  await db.execute({
69
69
  sql: `UPDATE org_invitations SET status = 'accepted' WHERE id = ?`,
@@ -1,4 +1,5 @@
1
1
  import { getDbExec } from "../db/client.js";
2
+ import { invalidateSessionEmailCache } from "../server/session-email-cache.js";
2
3
  export const GOOGLE_AUTH_REQUIRED_MESSAGE = "This organization requires Google sign-in.";
3
4
  function normalizeEmail(email) {
4
5
  return email.trim().toLowerCase();
@@ -152,6 +153,9 @@ export async function setRequiredAuthProvider(orgId, provider) {
152
153
  if (!isMissingLegacySessionTable(error))
153
154
  throw error;
154
155
  }
156
+ // Revoking a whole org's sessions must land immediately, not after the
157
+ // resolution cache's TTL.
158
+ invalidateSessionEmailCache();
155
159
  return {
156
160
  revokedBetterAuthSessions: Number(betterAuthResult.rowsAffected ?? 0),
157
161
  revokedLegacySessions: Number(legacyResult.rowsAffected ?? 0),
@@ -4,6 +4,15 @@ export interface AutoJoinDomainResult {
4
4
  }>;
5
5
  activeOrgId: string | null;
6
6
  }
7
+ /**
8
+ * Call after any write that could make a domain start matching an org —
9
+ * `organizations.allowed_domain` updates and org creation. Clears every domain
10
+ * rather than one: the caller knows which org changed, not which domains a
11
+ * stale negative was recorded for.
12
+ */
13
+ export declare function invalidateDomainMatchCache(): void;
14
+ /** Test seam — the cache is module state, so suites must be able to clear it. */
15
+ export declare function __resetDomainMatchCacheForTests(): void;
7
16
  export interface AutoJoinDomainOptions {
8
17
  /**
9
18
  * The signup hook should not clobber an org selected by an invite flow, but
@@ -1,9 +1,46 @@
1
1
  import { getDbExec } from "../db/client.js";
2
2
  import { getUserSetting } from "../settings/user-settings.js";
3
+ import { createTtlCache } from "../shared/ttl-cache.js";
3
4
  import { setActiveOrgId } from "./active-org.js";
4
- import { invalidateRequestMemberOrgIds } from "./request-org-cache.js";
5
+ import { isFreeEmailProvider } from "./free-email-providers.js";
6
+ import { invalidateMemberOrgCaches } from "./request-org-cache.js";
5
7
  const nanoid = () => globalThis.crypto?.randomUUID?.().replace(/-/g, "") ??
6
8
  Math.random().toString(36).slice(2) + Date.now().toString(36);
9
+ /**
10
+ * Negative cache for "no org has this email domain as its `allowed_domain`".
11
+ *
12
+ * `resolveOrgContext` calls this function on EVERY authenticated request for
13
+ * any account that holds no membership in a domain-matched org — which is every
14
+ * solo user and every user in a team org that never configured domain matching.
15
+ * The probe finds nothing, changes no state, and runs again on the next
16
+ * request: production showed 318k of these, ~64% of all authenticated requests,
17
+ * with no fixed point because not-joining an org is not a state the user can
18
+ * leave.
19
+ *
20
+ * Keyed on the DOMAIN, not the email, so one entry covers every account at that
21
+ * domain. Only a SUCCESSFUL zero-row read is cached — a failed probe stays on
22
+ * the uncached path so an unreadable `organizations` table never masquerades as
23
+ * "no matching org". Invalidated whenever `allowed_domain` is written, so
24
+ * correctness does not rest on the TTL.
25
+ */
26
+ const NO_DOMAIN_MATCH_TTL_MS = 60_000;
27
+ const noDomainMatchCache = createTtlCache({
28
+ ttlMs: NO_DOMAIN_MATCH_TTL_MS,
29
+ maxEntries: 512,
30
+ });
31
+ /**
32
+ * Call after any write that could make a domain start matching an org —
33
+ * `organizations.allowed_domain` updates and org creation. Clears every domain
34
+ * rather than one: the caller knows which org changed, not which domains a
35
+ * stale negative was recorded for.
36
+ */
37
+ export function invalidateDomainMatchCache() {
38
+ noDomainMatchCache.clear();
39
+ }
40
+ /** Test seam — the cache is module state, so suites must be able to clear it. */
41
+ export function __resetDomainMatchCacheForTests() {
42
+ noDomainMatchCache.clear();
43
+ }
7
44
  /**
8
45
  * Auto-join a newly-signed-up user into every org whose `allowed_domain`
9
46
  * matches their email domain.
@@ -31,6 +68,14 @@ export async function autoJoinDomainMatchingOrgs(rawEmail, options = {}) {
31
68
  const domain = email.split("@")[1]?.toLowerCase();
32
69
  if (!domain)
33
70
  return { joined: [], activeOrgId: null };
71
+ // `org/handlers.ts` REFUSES to set `allowed_domain` to a free provider, so no
72
+ // org can ever match `gmail.com` — every consumer-email account was probing
73
+ // for a row that is structurally impossible to exist.
74
+ if (isFreeEmailProvider(domain))
75
+ return { joined: [], activeOrgId: null };
76
+ if (noDomainMatchCache.get(domain)) {
77
+ return { joined: [], activeOrgId: null };
78
+ }
34
79
  const db = getDbExec();
35
80
  let matches = [];
36
81
  try {
@@ -53,11 +98,15 @@ export async function autoJoinDomainMatchingOrgs(rawEmail, options = {}) {
53
98
  }
54
99
  catch {
55
100
  // Template without org tables (or `allowed_domain` column not yet
56
- // migrated). Not fatal — return empty.
101
+ // migrated). Not fatal — return empty. Deliberately NOT cached: this branch
102
+ // cannot tell "no org matches" from "the table was unreadable", and caching
103
+ // it would let one blip answer every later request for a minute.
57
104
  return { joined: [], activeOrgId: null };
58
105
  }
59
- if (matches.length === 0)
106
+ if (matches.length === 0) {
107
+ noDomainMatchCache.set(domain, true);
60
108
  return { joined: [], activeOrgId: null };
109
+ }
61
110
  const joined = [];
62
111
  for (const m of matches) {
63
112
  try {
@@ -66,7 +115,7 @@ export async function autoJoinDomainMatchingOrgs(rawEmail, options = {}) {
66
115
  args: [nanoid(), m.orgId, email, Date.now()],
67
116
  });
68
117
  joined.push({ orgId: m.orgId });
69
- invalidateRequestMemberOrgIds();
118
+ invalidateMemberOrgCaches();
70
119
  }
71
120
  catch {
72
121
  // Race with a parallel join (e.g. user accepted an invite to the
@@ -5,7 +5,8 @@ import { getSetting } from "../settings/store.js";
5
5
  import { getUserSetting } from "../settings/user-settings.js";
6
6
  import { setActiveOrgId } from "./active-org.js";
7
7
  import { autoJoinDomainMatchingOrgs } from "./auto-join-domain.js";
8
- import { invalidateRequestMemberOrgIds, requestMemberOrgIds, } from "./request-org-cache.js";
8
+ import { isFreeEmailProvider } from "./free-email-providers.js";
9
+ import { cachedMemberships, invalidateMemberOrgCaches, requestMemberOrgIds, } from "./request-org-cache.js";
9
10
  const EMPTY_CONTEXT = {
10
11
  email: "",
11
12
  orgId: null,
@@ -197,8 +198,13 @@ async function resolveOrgContextUncached(event) {
197
198
  // signal. Recognizing the personal workspace by its *name* instead breaks the
198
199
  // moment the provider display name or the workspace name changes, which
199
200
  // strands an existing user in Personal with no way into their company org.
201
+ // `isFreeEmailProvider` short-circuits before the round trip rather than
202
+ // relying on the negative cache inside `autoJoinDomainMatchingOrgs`: a
203
+ // consumer-email domain can NEVER match (see `org/handlers.ts`, which refuses
204
+ // to set `allowed_domain` to a free provider), so it needs no TTL at all.
200
205
  const shouldTryDomainAutoJoin = !explicitPersonal &&
201
206
  emailDomain !== null &&
207
+ !isFreeEmailProvider(emailDomain) &&
202
208
  !memberships.some((m) => m.allowedDomain?.toLowerCase() === emailDomain);
203
209
  if (!explicitPersonal &&
204
210
  activeOrgSetting?.orgId &&
@@ -307,6 +313,9 @@ async function resolveOrgContextUncached(event) {
307
313
  */
308
314
  const MEMBERSHIP_FALLBACK_ORDER_BY = `ORDER BY joined_at ASC, org_id ASC`;
309
315
  async function loadMemberships(email) {
316
+ return cachedMemberships(email, () => loadMembershipsUncached(email));
317
+ }
318
+ async function loadMembershipsUncached(email) {
310
319
  const rows = await queryOrgMembers({
311
320
  sql: `SELECT m.org_id AS "orgId", m.role AS role, o.name AS "orgName",
312
321
  o.allowed_domain AS "allowedDomain"
@@ -403,7 +412,7 @@ export async function createOrganization(name, email, role = "owner") {
403
412
  sql: `INSERT INTO org_members (id, org_id, email, role, joined_at) VALUES (?, ?, ?, ?, ?)`,
404
413
  args: [nanoid(), id, email, role, createdAt],
405
414
  });
406
- invalidateRequestMemberOrgIds();
415
+ invalidateMemberOrgCaches();
407
416
  await warnOnAdditionalOrganization(exec, email, id, trimmedName);
408
417
  await setActiveOrgId(email, id, `created organization "${trimmedName}"`);
409
418
  return { id, name: trimmedName, role, a2aSecret, createdAt };
@@ -558,7 +567,7 @@ async function tryCreateDefaultOrg(exec, email, session) {
558
567
  sql: `INSERT INTO org_members (id, org_id, email, role, joined_at) VALUES (?, ?, ?, ?, ?)`,
559
568
  args: [nanoid(), orgId, email, "owner", now],
560
569
  });
561
- invalidateRequestMemberOrgIds();
570
+ invalidateMemberOrgCaches();
562
571
  await setActiveOrgId(email, orgId, "auto-created default organization");
563
572
  return { email, orgId, orgName, role: "owner" };
564
573
  }
@@ -37,9 +37,10 @@ import { sendEmail, isEmailConfigured } from "../server/email.js";
37
37
  import { readBody } from "../server/h3-helpers.js";
38
38
  import { setActiveOrgId } from "./active-org.js";
39
39
  import { setRequiredAuthProvider } from "./auth-policy.js";
40
+ import { invalidateDomainMatchCache } from "./auto-join-domain.js";
40
41
  import { getOrgContext, createOrganization } from "./context.js";
41
42
  import { isFreeEmailProvider } from "./free-email-providers.js";
42
- import { invalidateRequestMemberOrgIds } from "./request-org-cache.js";
43
+ import { invalidateMemberOrgCaches } from "./request-org-cache.js";
43
44
  import { parseWorkspaceUrl } from "./workspace-url.js";
44
45
  function getInviteAppUrl(event) {
45
46
  return getAppProductionUrl(event);
@@ -426,7 +427,7 @@ export const acceptInvitationHandler = defineEventHandler(async (event) => {
426
427
  sql: `INSERT INTO org_members (id, org_id, email, role, joined_at) VALUES (?, ?, ?, ?, ?)`,
427
428
  args: [nanoid(), invOrgId, email, inviteRole, Date.now()],
428
429
  });
429
- invalidateRequestMemberOrgIds();
430
+ invalidateMemberOrgCaches();
430
431
  await e.execute({
431
432
  sql: `UPDATE org_invitations SET status = 'accepted' WHERE id = ?`,
432
433
  args: [invitationId],
@@ -487,7 +488,7 @@ export const removeMemberHandler = defineEventHandler(async (event) => {
487
488
  sql: `DELETE FROM org_members WHERE org_id = ? AND LOWER(email) = ?`,
488
489
  args: [ctx.orgId, memberEmailLower],
489
490
  });
490
- invalidateRequestMemberOrgIds();
491
+ invalidateMemberOrgCaches();
491
492
  return { success: true };
492
493
  });
493
494
  /**
@@ -555,6 +556,7 @@ export const changeMemberRoleHandler = defineEventHandler(async (event) => {
555
556
  sql: `UPDATE org_members SET role = ? WHERE org_id = ? AND LOWER(email) = ?`,
556
557
  args: [role, ctx.orgId, memberEmailLower],
557
558
  });
559
+ invalidateMemberOrgCaches();
558
560
  return { email: memberEmailLower, role };
559
561
  });
560
562
  /** PATCH /_agent-native/org — rename the current organization (owner/admin only) */
@@ -582,6 +584,9 @@ export const updateOrgHandler = defineEventHandler(async (event) => {
582
584
  sql: `UPDATE organizations SET name = ? WHERE id = ?`,
583
585
  args: [name, ctx.orgId],
584
586
  });
587
+ // `orgName` is joined into the cached membership rows, so a rename that skips
588
+ // this leaves every member's org context showing the old name for the TTL.
589
+ invalidateMemberOrgCaches();
585
590
  return { orgId: ctx.orgId, name };
586
591
  });
587
592
  /**
@@ -661,7 +666,7 @@ export const deleteOrgHandler = defineEventHandler(async (event) => {
661
666
  message: "Organization deletion requires atomic database support",
662
667
  });
663
668
  }
664
- invalidateRequestMemberOrgIds();
669
+ invalidateMemberOrgCaches();
665
670
  const nextRes = await e.execute({
666
671
  sql: `SELECT org_id AS "orgId" FROM org_members WHERE LOWER(email) = ? LIMIT 1`,
667
672
  args: [ctx.email.toLowerCase()],
@@ -744,7 +749,7 @@ export const joinByDomainHandler = defineEventHandler(async (event) => {
744
749
  sql: `INSERT INTO org_members (id, org_id, email, role, joined_at) VALUES (?, ?, ?, 'member', ?)`,
745
750
  args: [nanoid(), orgId, email, Date.now()],
746
751
  });
747
- invalidateRequestMemberOrgIds();
752
+ invalidateMemberOrgCaches();
748
753
  await setActiveOrgId(email, orgId, "joined domain-matched organization");
749
754
  return {
750
755
  orgId,
@@ -813,6 +818,13 @@ export const setDomainHandler = defineEventHandler(async (event) => {
813
818
  sql: `UPDATE organizations SET allowed_domain = ? WHERE id = ?`,
814
819
  args: [raw, ctx.orgId],
815
820
  });
821
+ // A domain that previously matched nothing now matches this org. Without this
822
+ // the negative cache keeps every account at that domain out of it for the
823
+ // rest of the TTL, right after an owner deliberately turned domain-join on.
824
+ invalidateDomainMatchCache();
825
+ // `allowedDomain` is joined into the cached membership rows too, and existing
826
+ // members read it to decide whether a domain auto-join is still needed.
827
+ invalidateMemberOrgCaches();
816
828
  return { domain: raw };
817
829
  });
818
830
  /**
@@ -6,10 +6,32 @@
6
6
  */
7
7
  export declare function requestMemberOrgIds(email: string, load: () => Promise<string[] | null>): Promise<string[] | null>;
8
8
  /**
9
- * Drop this request's memoized memberships after a write to `org_members`.
9
+ * Read `email`'s memberships through the process cache, falling back to `load`.
10
+ *
11
+ * `load` returning `null`/empty-on-failure is the caller's contract to signal
12
+ * "unreadable"; pass `cacheable: false` for such a result and it is not stored.
13
+ */
14
+ export declare function cachedMemberships<T>(email: string, load: () => Promise<T[] | null>): Promise<T[] | null>;
15
+ /**
16
+ * Drop the memoized memberships after a write to `org_members`, in BOTH the
17
+ * per-request memo and the cross-request cache.
18
+ *
10
19
  * Clears every email rather than one: deleting an organization or removing a
11
20
  * member changes the answer for accounts other than the one being written.
12
21
  * Requests already in flight elsewhere keep their own snapshot for the rest of
13
22
  * their (short) lifetime, the same tradeoff the settings cache documents.
23
+ *
24
+ * Every membership write must route through here. A caller that clears only the
25
+ * request memo leaves the process cache serving the pre-write answer for the
26
+ * rest of the TTL — the user joins an org and the app keeps insisting they
27
+ * haven't.
28
+ *
29
+ * "Membership write" is wider than `INSERT`/`DELETE org_members`: the cached
30
+ * rows are a JOIN, so `org_members.role` and the `organizations.name` /
31
+ * `organizations.allowed_domain` columns it selects are cached too. Demoting an
32
+ * admin, renaming an org, or turning domain-join on without calling this keeps
33
+ * the pre-write answer authorizing and rendering requests until the TTL lapses.
14
34
  */
15
- export declare function invalidateRequestMemberOrgIds(): void;
35
+ export declare function invalidateMemberOrgCaches(): void;
36
+ /** Test seam — the process cache is module state, so suites must clear it. */
37
+ export declare function __resetProcessMemberOrgCacheForTests(): void;
@@ -1,4 +1,5 @@
1
1
  import { getRequestContext } from "../server/request-context.js";
2
+ import { createTtlCache } from "../shared/ttl-cache.js";
2
3
  /**
3
4
  * Per-request memo of the `org_members` read behind `resolveOrgIdForEmail`,
4
5
  * keyed on the active AsyncLocalStorage `RequestContext` (WeakMap → freed with
@@ -50,12 +51,66 @@ export function requestMemberOrgIds(email, load) {
50
51
  return pending;
51
52
  }
52
53
  /**
53
- * Drop this request's memoized memberships after a write to `org_members`.
54
+ * Cross-request cache for the full membership rows behind `getOrgContext`.
55
+ *
56
+ * The per-request memo above only collapses repeated reads inside ONE request.
57
+ * Every authenticated request still paid its own `org_members` round trip, and
58
+ * production showed 494,785 of them — one per request, 1:1 with the session
59
+ * lookup, for memberships that change on the order of days.
60
+ *
61
+ * Only a SUCCESSFUL read is stored. `loadMemberships` returns `null` for an
62
+ * unreadable `org_members` (no such table on a template that skips the org
63
+ * module, a role without SELECT); caching that would turn a permissions blip
64
+ * into a minute of silently org-less requests, which drops org scope and hides
65
+ * every org-scoped credential behind a permanent-sounding "not configured".
66
+ */
67
+ const MEMBER_ORGS_TTL_MS = 15_000;
68
+ const processMemberships = createTtlCache({
69
+ ttlMs: MEMBER_ORGS_TTL_MS,
70
+ maxEntries: 2_048,
71
+ });
72
+ /**
73
+ * Read `email`'s memberships through the process cache, falling back to `load`.
74
+ *
75
+ * `load` returning `null`/empty-on-failure is the caller's contract to signal
76
+ * "unreadable"; pass `cacheable: false` for such a result and it is not stored.
77
+ */
78
+ export async function cachedMemberships(email, load) {
79
+ const key = email.trim().toLowerCase();
80
+ const hit = processMemberships.get(key);
81
+ if (hit)
82
+ return hit;
83
+ const rows = await load();
84
+ // `null` is "unreadable", never "no memberships" — see the doc comment above.
85
+ if (rows !== null)
86
+ processMemberships.set(key, rows);
87
+ return rows;
88
+ }
89
+ /**
90
+ * Drop the memoized memberships after a write to `org_members`, in BOTH the
91
+ * per-request memo and the cross-request cache.
92
+ *
54
93
  * Clears every email rather than one: deleting an organization or removing a
55
94
  * member changes the answer for accounts other than the one being written.
56
95
  * Requests already in flight elsewhere keep their own snapshot for the rest of
57
96
  * their (short) lifetime, the same tradeoff the settings cache documents.
97
+ *
98
+ * Every membership write must route through here. A caller that clears only the
99
+ * request memo leaves the process cache serving the pre-write answer for the
100
+ * rest of the TTL — the user joins an org and the app keeps insisting they
101
+ * haven't.
102
+ *
103
+ * "Membership write" is wider than `INSERT`/`DELETE org_members`: the cached
104
+ * rows are a JOIN, so `org_members.role` and the `organizations.name` /
105
+ * `organizations.allowed_domain` columns it selects are cached too. Demoting an
106
+ * admin, renaming an org, or turning domain-join on without calling this keeps
107
+ * the pre-write answer authorizing and rendering requests until the TTL lapses.
58
108
  */
59
- export function invalidateRequestMemberOrgIds() {
109
+ export function invalidateMemberOrgCaches() {
60
110
  cacheForRequest(false)?.clear();
111
+ processMemberships.clear();
112
+ }
113
+ /** Test seam — the process cache is module state, so suites must clear it. */
114
+ export function __resetProcessMemberOrgCacheForTests() {
115
+ processMemberships.clear();
61
116
  }