@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.
- package/corpus/README.md +1 -1
- package/corpus/templates/clips/changelog/2026-08-06-desktop-full-screen-recordings-now-normalize-microphone-leve.md +6 -0
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen/custom_capture.rs +185 -69
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +47 -4
- package/corpus/templates/forms/server/plugins/agent-chat.ts +1 -1
- package/corpus/templates/slides/server/plugins/agent-chat.ts +1 -1
- package/dist/agent/production-agent.d.ts +6 -0
- package/dist/agent/run-manager.d.ts +44 -1
- package/dist/agent/run-manager.js +70 -8
- package/dist/chat-threads/store.js +6 -4
- package/dist/client/use-db-sync.js +9 -1
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/db/ddl-guard.d.ts +8 -0
- package/dist/db/ddl-guard.js +148 -0
- package/dist/deploy/build.d.ts +36 -0
- package/dist/deploy/build.js +78 -5
- package/dist/framework-tools.d.ts +126 -0
- package/dist/framework-tools.js +114 -0
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/org/accept-pending.js +2 -2
- package/dist/org/auth-policy.js +4 -0
- package/dist/org/auto-join-domain.d.ts +9 -0
- package/dist/org/auto-join-domain.js +53 -4
- package/dist/org/context.js +12 -3
- package/dist/org/handlers.js +17 -5
- package/dist/org/request-org-cache.d.ts +24 -2
- package/dist/org/request-org-cache.js +57 -2
- package/dist/provider-api/actions/custom-provider-registration.d.ts +6 -6
- package/dist/provider-api/actions/provider-api.d.ts +6 -6
- package/dist/resources/store.d.ts +1 -1
- package/dist/resources/store.js +96 -7
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/action-discovery.d.ts +27 -0
- package/dist/server/action-discovery.js +89 -0
- package/dist/server/agent-chat/action-filters-a2a.d.ts +11 -0
- package/dist/server/agent-chat/action-filters-a2a.js +17 -1
- package/dist/server/agent-chat/framework-prompts.d.ts +2 -0
- package/dist/server/agent-chat/plugin-options.d.ts +30 -0
- package/dist/server/agent-chat/prompt-resources.d.ts +4 -1
- package/dist/server/agent-chat/prompt-resources.js +8 -2
- package/dist/server/agent-chat-plugin.js +138 -75
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/auth.d.ts +3 -0
- package/dist/server/auth.js +22 -1
- package/dist/server/collab-plugin.js +10 -4
- package/dist/server/core-routes-plugin.js +3 -0
- package/dist/server/prompts/framework-core-compact.d.ts +5 -0
- package/dist/server/prompts/framework-core-compact.js +28 -12
- package/dist/server/prompts/framework-core.d.ts +5 -0
- package/dist/server/prompts/framework-core.js +33 -16
- package/dist/server/prompts/index.d.ts +1 -1
- package/dist/server/prompts/index.js +1 -1
- package/dist/server/prompts/shared-rules.d.ts +12 -4
- package/dist/server/prompts/shared-rules.js +14 -4
- package/dist/server/session-email-cache.d.ts +14 -0
- package/dist/server/session-email-cache.js +48 -0
- package/dist/server/sweep-runtime.d.ts +22 -0
- package/dist/server/sweep-runtime.js +28 -0
- package/dist/settings/store.js +63 -7
- package/dist/shared/ttl-cache.d.ts +59 -0
- package/dist/shared/ttl-cache.js +43 -0
- package/dist/sharing/actions/list-resource-shares.d.ts +3 -3
- package/dist/sharing/actions/list-resource-shares.js +4 -1
- package/dist/templates/workspace-core/.agents/skills/extensions/SKILL.md +6 -3
- package/dist/triggers/condition-evaluator.js +11 -27
- package/docs/content/deployment.mdx +95 -13
- package/docs/content/environment-variables.mdx +5 -0
- package/docs/content/key-concepts.mdx +13 -6
- package/package.json +1 -1
- 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 {
|
|
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
|
-
|
|
66
|
+
invalidateMemberOrgCaches();
|
|
67
67
|
}
|
|
68
68
|
await db.execute({
|
|
69
69
|
sql: `UPDATE org_invitations SET status = 'accepted' WHERE id = ?`,
|
package/dist/org/auth-policy.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
118
|
+
invalidateMemberOrgCaches();
|
|
70
119
|
}
|
|
71
120
|
catch {
|
|
72
121
|
// Race with a parallel join (e.g. user accepted an invite to the
|
package/dist/org/context.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
570
|
+
invalidateMemberOrgCaches();
|
|
562
571
|
await setActiveOrgId(email, orgId, "auto-created default organization");
|
|
563
572
|
return { email, orgId, orgName, role: "owner" };
|
|
564
573
|
}
|
package/dist/org/handlers.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
}
|