@k2b/cloud 0.30.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -4
- package/src/_internal/app-presentation.ts +22 -1
- package/src/_internal/capabilities.ts +2 -2
- package/src/_internal/define-app.ts +17 -5
- package/src/_internal/registry.ts +50 -6
- package/src/_internal/widget-declarations.ts +53 -0
- package/src/ai/app-skills.ts +1 -7
- package/src/ai/approval-routes.ts +40 -4
- package/src/ai/approvals.ts +68 -10
- package/src/ai/audio-tool.ts +2 -1
- package/src/ai/browser.ts +109 -0
- package/src/ai/capabilities.ts +6 -2
- package/src/ai/chart-block.ts +431 -81
- package/src/ai/chat/action-sentences.ts +13 -7
- package/src/ai/chat/blocks.tsx +64 -39
- package/src/ai/chat/builtin-tools.tsx +9 -7
- package/src/ai/chat/chart-block.tsx +218 -44
- package/src/ai/chat/explorer-block.tsx +276 -0
- package/src/ai/chat/file-tools.tsx +40 -1
- package/src/ai/chat/live-turn.browser-harness.tsx +4 -1
- package/src/ai/chat/message-actions.tsx +9 -1
- package/src/ai/chat/message-utils.ts +7 -0
- package/src/ai/chat/messages.ts +34 -0
- package/src/ai/chat/presentation.tsx +13 -3
- package/src/ai/chat/reference-text.tsx +34 -0
- package/src/ai/chat/tool-groups.ts +67 -12
- package/src/ai/chat/tool-names.ts +18 -2
- package/src/ai/chat/turn-layout.ts +8 -0
- package/src/ai/chat/turn-view.tsx +111 -32
- package/src/ai/chat/view-block.browser-harness.tsx +35 -0
- package/src/ai/chat/view-block.tsx +417 -0
- package/src/ai/chat/visual-tools.tsx +32 -53
- package/src/ai/chat/web-tools.tsx +95 -1
- package/src/ai/client/controller.ts +6 -3
- package/src/ai/client-tool-results.ts +9 -0
- package/src/ai/code-runtime-tools.ts +50 -17
- package/src/ai/code-source-contracts.ts +25 -21
- package/src/ai/code-source-tools.ts +3 -1
- package/src/ai/default-tool-contracts.ts +334 -91
- package/src/ai/default-tools.ts +87 -11
- package/src/ai/executor.ts +343 -45
- package/src/ai/fetch-file-tool.ts +41 -4
- package/src/ai/file-error.ts +2 -0
- package/src/ai/file-tools.ts +27 -7
- package/src/ai/files-store.ts +60 -0
- package/src/ai/firecrawl-tools.ts +87 -4
- package/src/ai/html-pdf-tool.ts +3 -2
- package/src/ai/http.ts +37 -12
- package/src/ai/index.ts +98 -0
- package/src/ai/local-tool-contracts.ts +244 -0
- package/src/ai/malformed-tool-call-repair.ts +144 -0
- package/src/ai/markdown-pdf-tool.ts +2 -1
- package/src/ai/migrate.ts +120 -2
- package/src/ai/openai-api-messages.ts +53 -0
- package/src/ai/openai-api-slots.ts +40 -0
- package/src/ai/openai-api.ts +383 -0
- package/src/ai/protocol.ts +28 -3
- package/src/ai/provider.ts +16 -4
- package/src/ai/request-context.ts +33 -0
- package/src/ai/routes.ts +99 -49
- package/src/ai/runtime.ts +90 -15
- package/src/ai/settings.ts +5 -0
- package/src/ai/skill-catalog.ts +20 -5
- package/src/ai/skill-format.ts +17 -7
- package/src/ai/skill-tool.ts +15 -5
- package/src/ai/skills-routes.ts +20 -6
- package/src/ai/skills.ts +26 -7
- package/src/ai/store.ts +91 -1
- package/src/ai/system-prompt.ts +69 -10
- package/src/ai/timeline.ts +3 -1
- package/src/ai/tool-audit.ts +11 -1
- package/src/ai/tools.ts +94 -3
- package/src/ai/types.ts +48 -0
- package/src/ai/view-block.ts +941 -0
- package/src/ai/vision-tool.ts +2 -1
- package/src/ai/web-provenance.ts +103 -0
- package/src/api/admin-identity.ts +6 -1
- package/src/api/app-approval-actions.ts +36 -0
- package/src/api/app-approval.ts +10 -2
- package/src/api/approvals-loader.ts +15 -0
- package/src/api/approvals.ts +120 -0
- package/src/api/capabilities.ts +3 -2
- package/src/api/index.ts +3 -0
- package/src/api/me.ts +12 -3
- package/src/api/widgets.ts +36 -5
- package/src/approvals/Approvals.browser-harness.tsx +16 -0
- package/src/approvals/Approvals.tsx +621 -0
- package/src/approvals/ApprovalsNavStatus.tsx +20 -0
- package/src/approvals/messages.ts +95 -0
- package/src/approvals/ui.ts +8 -0
- package/src/browser/CloudResourceSearch.tsx +1 -1
- package/src/browser/app-approval.ts +32 -0
- package/src/browser/choose-files.tsx +26 -13
- package/src/browser/command-shortcuts.ts +21 -6
- package/src/browser/commands.ts +13 -4
- package/src/browser/file-provider-error.ts +11 -0
- package/src/browser/file-providers.ts +3 -12
- package/src/browser/files.tsx +1 -2
- package/src/browser/live-connection.ts +2 -3
- package/src/browser/provider-list.ts +2 -1
- package/src/browser/save-files.tsx +13 -2
- package/src/browser/spotlight-position.ts +4 -1
- package/src/browser/widgets.ts +7 -5
- package/src/capabilities/executions.ts +3 -0
- package/src/cli/account.ts +21 -2
- package/src/cli/commands.ts +24 -2
- package/src/cli/index.ts +16 -0
- package/src/cli/plugin.ts +30 -0
- package/src/contracts/app-approval.ts +120 -0
- package/src/contracts/app.ts +34 -2
- package/src/contracts/approval-paths.ts +4 -0
- package/src/contracts/approvals.ts +81 -0
- package/src/contracts/command-path.ts +17 -0
- package/src/contracts/commands.ts +5 -15
- package/src/contracts/registry.ts +9 -1
- package/src/contracts/shared.ts +5 -0
- package/src/contracts/widgets.ts +47 -2
- package/src/desktop/solid.tsx +6 -7
- package/src/events/live-protocol.ts +2 -1
- package/src/events/live-server-message.ts +40 -0
- package/src/events/testing.ts +88 -0
- package/src/server/index.ts +3 -0
- package/src/server/middleware/auth.ts +11 -1
- package/src/server/request-timeout.ts +22 -0
- package/src/server/services/access.ts +4 -3
- package/src/server/widget-request.ts +27 -0
- package/src/services/app-approval.ts +103 -15
- package/src/services/approvals/cursor.ts +15 -0
- package/src/services/approvals/device.ts +112 -0
- package/src/services/approvals/index.ts +241 -0
- package/src/services/approvals/list.ts +120 -0
- package/src/services/approvals/migrate.ts +41 -0
- package/src/services/identity/models-only-auth.ts +25 -0
- package/src/services/notifications/email-frame.ts +16 -3
- package/src/services/service-account-credentials.ts +22 -10
- package/src/services/settings/core-settings.ts +27 -0
- package/src/shared/ai-platform-prompt.ts +26 -1
- package/src/shared/app-presentation.ts +11 -0
- package/src/shared/email-html.ts +46 -7
- package/src/shared/index.ts +6 -1
- package/src/shared/markdown/extensions/code.ts +14 -0
- package/src/shared/markdown/index.ts +4 -4
- package/src/ssr/AdminLayout.tsx +4 -7
- package/src/ssr/AppBadge.tsx +4 -4
- package/src/ssr/AppLaunchpad.island.tsx +1 -1
- package/src/ssr/AppLaunchpadPanel.tsx +4 -4
- package/src/ssr/Layout.tsx +38 -14
- package/src/ssr/LayoutFooter.tsx +1 -1
- package/src/ssr/LayoutHeader.tsx +17 -20
- package/src/ssr/LayoutHelp.tsx +1 -1
- package/src/ssr/LayoutRail.tsx +1 -2
- package/src/ssr/PageTrail.tsx +23 -0
- package/src/ssr/PageTrailRow.island.tsx +20 -0
- package/src/ssr/PageTrailRuntime.island.tsx +26 -0
- package/src/ssr/ProfilePreferences.island.tsx +7 -3
- package/src/ssr/RailApps.island.tsx +2 -2
- package/src/ssr/app-badges.ts +50 -6
- package/src/ssr/islands/index.ts +1 -0
- package/src/ssr/layout-runtime.ts +60 -0
- package/src/ssr/mobile-menu-history.ts +1 -1
- package/src/ssr/page-trail-runtime.ts +313 -0
- package/src/ssr/page-trail.browser-harness.tsx +265 -0
- package/src/ssr/page-trail.ts +59 -0
- package/src/ssr/platform-messages.ts +4 -0
- package/src/ssr/rail-navigation.ts +5 -3
- package/src/ssr/runtime.ts +6 -1
- package/src/styles/approvals.css +390 -0
- package/src/styles/effects.css +21 -4
- package/src/styles/global.css +39 -3
- package/src/styles/utilities-layout.css +24 -1
- package/src/styles/utilities-navigation.css +6 -18
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { type SQL, sql } from "bun";
|
|
2
|
+
import { capabilityRequestHash } from "../../capabilities/claims";
|
|
3
|
+
import { APPROVAL_ACP_CLIENT } from "../../contracts/approvals";
|
|
4
|
+
import type { CapabilityActionReview } from "../../contracts/capabilities";
|
|
5
|
+
import { isAppSessionActor } from "../../server/actor";
|
|
6
|
+
import type { RequestActor, RequestCredentialKind } from "../../server/middleware/auth";
|
|
7
|
+
import { audit } from "../audit";
|
|
8
|
+
import { toPgTextArray } from "../postgres";
|
|
9
|
+
|
|
10
|
+
export { countPending, listPending, presentPendingApproval } from "./list";
|
|
11
|
+
export { migrateApprovals } from "./migrate";
|
|
12
|
+
|
|
13
|
+
// Derived from chat's AI_ACTION_BUDGET_MS and Studio's one-day abandonment of pending calls.
|
|
14
|
+
export const APPROVAL_MAX_WAIT_MS = 24 * 60 * 60_000;
|
|
15
|
+
export type ApprovalDecisionChannel = "web_session" | "app_session" | "oauth" | "api_key" | "invocation" | "acp_bridge" | "cloud_login";
|
|
16
|
+
export type ApprovalRequesterKind = "assistant_turn" | "studio_app";
|
|
17
|
+
export type ApprovalStatus = "pending" | "approved" | "rejected" | "expired" | "cancelled" | "consumed";
|
|
18
|
+
export type ApprovalAssurance = "standard" | "confirm";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `standard` when the person may remember the approval of this call: an Action with `approval: "rememberable"` whose
|
|
22
|
+
* review names a scope for these arguments, a tool whose policy allows remembering, or a website. Otherwise `confirm`.
|
|
23
|
+
*/
|
|
24
|
+
export const approvalAssurance = (rememberable: boolean): ApprovalAssurance => (rememberable ? "standard" : "confirm");
|
|
25
|
+
export const approvalArgsDigest = (input: unknown): string => capabilityRequestHash(input);
|
|
26
|
+
export const approvalReviewDigest = (review: CapabilityActionReview | null | undefined): string => capabilityRequestHash(review ?? null);
|
|
27
|
+
export const approvalDecisionChannel = (input: {
|
|
28
|
+
credentialKind?: RequestCredentialKind;
|
|
29
|
+
actor?: RequestActor;
|
|
30
|
+
client?: string | null;
|
|
31
|
+
}): ApprovalDecisionChannel =>
|
|
32
|
+
input.client === APPROVAL_ACP_CLIENT
|
|
33
|
+
? "acp_bridge"
|
|
34
|
+
: input.credentialKind === "session"
|
|
35
|
+
? isAppSessionActor(input.actor)
|
|
36
|
+
? "app_session"
|
|
37
|
+
: "web_session"
|
|
38
|
+
: (input.credentialKind ?? "invocation");
|
|
39
|
+
export const approvalScopeFromToolName = (name: string): { appId: string; action: string } => {
|
|
40
|
+
const dot = name.indexOf(".");
|
|
41
|
+
return dot < 0 ? { appId: "assistant", action: name } : { appId: name.slice(0, dot), action: name.slice(dot + 1) };
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type ApprovalRequestInput = {
|
|
45
|
+
appId: string;
|
|
46
|
+
action: string;
|
|
47
|
+
approvalScope?: string | null;
|
|
48
|
+
requester: { kind: ApprovalRequesterKind; ref: string; appId: string; title?: string | null };
|
|
49
|
+
subjectId: string;
|
|
50
|
+
approverUserId: string;
|
|
51
|
+
assurance: ApprovalAssurance;
|
|
52
|
+
sentence: string;
|
|
53
|
+
review?: CapabilityActionReview | null;
|
|
54
|
+
input: unknown;
|
|
55
|
+
expiresInMs: number;
|
|
56
|
+
};
|
|
57
|
+
export type ApprovalRequest = {
|
|
58
|
+
id: string;
|
|
59
|
+
appId: string;
|
|
60
|
+
action: string;
|
|
61
|
+
approvalScope: string | null;
|
|
62
|
+
requesterKind: ApprovalRequesterKind;
|
|
63
|
+
requesterRef: string;
|
|
64
|
+
requesterAppId: string;
|
|
65
|
+
requesterTitle: string | null;
|
|
66
|
+
subjectId: string;
|
|
67
|
+
approverUserId: string;
|
|
68
|
+
assurance: ApprovalAssurance;
|
|
69
|
+
sentence: string;
|
|
70
|
+
details: CapabilityActionReview | null;
|
|
71
|
+
argsDigest: string;
|
|
72
|
+
reviewDigest: string;
|
|
73
|
+
status: ApprovalStatus;
|
|
74
|
+
decidedVia: ApprovalDecisionChannel | null;
|
|
75
|
+
decidedAt: Date | null;
|
|
76
|
+
consumedAt: Date | null;
|
|
77
|
+
createdAt: Date;
|
|
78
|
+
expiresAt: Date;
|
|
79
|
+
};
|
|
80
|
+
// SQL aliases keep the public row shape identical for reads and locked transaction reads. A function, because a
|
|
81
|
+
// fragment built at import time would bind the default sql handle before a test can choose its database.
|
|
82
|
+
const columns = () => sql`id, app_id AS "appId", action, approval_scope AS "approvalScope",
|
|
83
|
+
requester_kind AS "requesterKind", requester_ref AS "requesterRef", requester_app_id AS "requesterAppId",
|
|
84
|
+
requester_title AS "requesterTitle", subject_id AS "subjectId", approver_user_id AS "approverUserId",
|
|
85
|
+
assurance, sentence, details, args_digest AS "argsDigest", review_digest AS "reviewDigest", status,
|
|
86
|
+
decided_via AS "decidedVia", decided_at AS "decidedAt", consumed_at AS "consumedAt",
|
|
87
|
+
created_at AS "createdAt", expires_at AS "expiresAt"`;
|
|
88
|
+
|
|
89
|
+
const transaction = <T>(db: SQL | undefined, run: (tx: SQL) => Promise<T>): Promise<T> => (db && db !== sql ? run(db) : sql.begin(run));
|
|
90
|
+
|
|
91
|
+
export type ApprovalRequestResult = { ok: true; id: string } | { ok: false; code: "CONFLICT" };
|
|
92
|
+
/**
|
|
93
|
+
* Records the question once per requester reference. Asking again, for example after a worker restart, returns the
|
|
94
|
+
* open request unchanged: its snapshot is what the person may already see, so a changed effect fails `consume` and
|
|
95
|
+
* asks again instead of rebinding that decision. A reference whose request is decided, expired, or another person's
|
|
96
|
+
* is a conflict.
|
|
97
|
+
*/
|
|
98
|
+
export const request = async (input: ApprovalRequestInput, db?: SQL): Promise<ApprovalRequestResult> => {
|
|
99
|
+
// Zero or less records a request that is already expired, such as a call that waited too long before the upgrade.
|
|
100
|
+
const expiresInMs = Number.isNaN(input.expiresInMs)
|
|
101
|
+
? APPROVAL_MAX_WAIT_MS
|
|
102
|
+
: Math.max(0, Math.min(input.expiresInMs, APPROVAL_MAX_WAIT_MS));
|
|
103
|
+
return transaction(db, async (tx): Promise<ApprovalRequestResult> => {
|
|
104
|
+
const [row] = await tx<{ id: string }[]>`
|
|
105
|
+
INSERT INTO approvals.requests (app_id, action, approval_scope, requester_kind, requester_ref, requester_app_id,
|
|
106
|
+
requester_title, subject_id, approver_user_id, assurance, sentence, details, args_digest, review_digest, expires_at)
|
|
107
|
+
VALUES (${input.appId}, ${input.action}, ${input.approvalScope ?? null}, ${input.requester.kind}, ${input.requester.ref},
|
|
108
|
+
${input.requester.appId}, ${input.requester.title ?? null}, ${input.subjectId}::uuid, ${input.approverUserId}::uuid,
|
|
109
|
+
${input.assurance}, ${input.sentence}, ${JSON.stringify(input.review ?? null)}::text::jsonb,
|
|
110
|
+
${approvalArgsDigest(input.input)}, ${approvalReviewDigest(input.review)}, now() + (${expiresInMs} * interval '1 millisecond'))
|
|
111
|
+
ON CONFLICT (requester_kind, requester_ref) DO NOTHING
|
|
112
|
+
RETURNING id
|
|
113
|
+
`;
|
|
114
|
+
if (row) return { ok: true, id: row.id };
|
|
115
|
+
const [open] = await tx<{ id: string }[]>`SELECT id FROM approvals.requests
|
|
116
|
+
WHERE requester_kind=${input.requester.kind} AND requester_ref=${input.requester.ref}
|
|
117
|
+
AND status='pending' AND expires_at > now() AND subject_id=${input.subjectId}::uuid
|
|
118
|
+
AND approver_user_id=${input.approverUserId}::uuid AND app_id=${input.appId} AND action=${input.action}`;
|
|
119
|
+
return open ? { ok: true, id: open.id } : { ok: false, code: "CONFLICT" };
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export const get = async (id: string, db?: SQL): Promise<ApprovalRequest | null> =>
|
|
124
|
+
transaction(db, async (tx) => {
|
|
125
|
+
const [row] = await tx<ApprovalRequest[]>`SELECT ${columns()} FROM approvals.requests WHERE id=${id}::uuid`;
|
|
126
|
+
return row ?? null;
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
export type ApprovalDecisionResult =
|
|
130
|
+
| { ok: true; status: ApprovalStatus; replay: boolean }
|
|
131
|
+
| { ok: false; code: "EXPIRED" | "NOT_FOUND" | "REJECT_ONLY" }
|
|
132
|
+
| { ok: false; code: "ALREADY_DECIDED"; status: ApprovalStatus };
|
|
133
|
+
export const decide = async (
|
|
134
|
+
id: string,
|
|
135
|
+
decision: { approved: boolean },
|
|
136
|
+
actor: { userId: string; via: ApprovalDecisionChannel; deviceId?: string },
|
|
137
|
+
db?: SQL,
|
|
138
|
+
): Promise<ApprovalDecisionResult> => {
|
|
139
|
+
const result = await transaction(db, async (tx): Promise<ApprovalDecisionResult> => {
|
|
140
|
+
// Lock the device before the request so revocation and the decision serialize.
|
|
141
|
+
if (actor.deviceId !== undefined) {
|
|
142
|
+
const [device] = await tx`SELECT 1 FROM auth.app_devices
|
|
143
|
+
WHERE id=${actor.deviceId}::uuid AND user_id=${actor.userId}::uuid AND revoked_at IS NULL FOR SHARE`;
|
|
144
|
+
if (!device) return { ok: false, code: "NOT_FOUND" };
|
|
145
|
+
}
|
|
146
|
+
const [row] = await tx<ApprovalRequest[]>`SELECT ${columns()} FROM approvals.requests
|
|
147
|
+
WHERE id=${id}::uuid AND approver_user_id=${actor.userId}::uuid FOR UPDATE`;
|
|
148
|
+
if (!row) return { ok: false, code: "NOT_FOUND" };
|
|
149
|
+
const rejectOnly = decision.approved && row.assurance === "confirm" && actor.via === "acp_bridge";
|
|
150
|
+
const status = decision.approved ? "approved" : "rejected";
|
|
151
|
+
const [won] = rejectOnly
|
|
152
|
+
? []
|
|
153
|
+
: await tx<ApprovalRequest[]>`
|
|
154
|
+
UPDATE approvals.requests SET status=${status}, decided_via=${actor.via}, decided_at=now()
|
|
155
|
+
WHERE id=${id}::uuid AND approver_user_id=${actor.userId}::uuid AND status='pending' AND expires_at > now()
|
|
156
|
+
RETURNING ${columns()}
|
|
157
|
+
`;
|
|
158
|
+
if (won) {
|
|
159
|
+
await audit.record(
|
|
160
|
+
{
|
|
161
|
+
action: decision.approved ? "auth.action.approve" : "auth.action.deny",
|
|
162
|
+
outcome: "allowed",
|
|
163
|
+
actor: { userId: actor.userId },
|
|
164
|
+
target: { type: "approval_request", id, label: `${won.appId}.${won.action}` },
|
|
165
|
+
metadata: {
|
|
166
|
+
appId: won.appId,
|
|
167
|
+
action: won.action,
|
|
168
|
+
assurance: won.assurance,
|
|
169
|
+
decidedVia: actor.via,
|
|
170
|
+
...(actor.deviceId !== undefined ? { deviceId: actor.deviceId } : {}),
|
|
171
|
+
requesterKind: won.requesterKind,
|
|
172
|
+
requesterAppId: won.requesterAppId,
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
tx,
|
|
176
|
+
);
|
|
177
|
+
return { ok: true, status, replay: false };
|
|
178
|
+
}
|
|
179
|
+
if (row.status === "pending") {
|
|
180
|
+
const [expired] = await tx<{ id: string }[]>`UPDATE approvals.requests SET status='expired'
|
|
181
|
+
WHERE id=${id}::uuid AND status='pending' AND expires_at <= now() RETURNING id`;
|
|
182
|
+
return { ok: false, code: expired || !rejectOnly ? "EXPIRED" : "REJECT_ONLY" };
|
|
183
|
+
}
|
|
184
|
+
if (
|
|
185
|
+
(decision.approved && (row.status === "approved" || row.status === "consumed")) ||
|
|
186
|
+
(!decision.approved && row.status === "rejected")
|
|
187
|
+
)
|
|
188
|
+
return { ok: true, status: row.status, replay: true };
|
|
189
|
+
return { ok: false, code: "ALREADY_DECIDED", status: row.status };
|
|
190
|
+
});
|
|
191
|
+
// Publish a later NATS decision hint only after the owning transaction commits.
|
|
192
|
+
return result;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
export type ApprovalConsumeResult =
|
|
196
|
+
| { ok: true }
|
|
197
|
+
| { ok: false; code: "NOT_FOUND" | "NOT_APPROVED" | "ALREADY_CONSUMED" | "INPUT_CHANGED" | "REVIEW_CHANGED" };
|
|
198
|
+
export const consume = async (
|
|
199
|
+
id: string,
|
|
200
|
+
input: { input: unknown; review: CapabilityActionReview | null; requester: { kind: ApprovalRequesterKind; ref: string } },
|
|
201
|
+
db?: SQL,
|
|
202
|
+
): Promise<ApprovalConsumeResult> => {
|
|
203
|
+
const argsDigest = approvalArgsDigest(input.input),
|
|
204
|
+
reviewDigest = approvalReviewDigest(input.review);
|
|
205
|
+
return transaction(db, async (tx): Promise<ApprovalConsumeResult> => {
|
|
206
|
+
const [row] = await tx<ApprovalRequest[]>`SELECT ${columns()} FROM approvals.requests
|
|
207
|
+
WHERE id=${id}::uuid AND requester_kind=${input.requester.kind} AND requester_ref=${input.requester.ref} FOR UPDATE`;
|
|
208
|
+
if (!row) return { ok: false, code: "NOT_FOUND" };
|
|
209
|
+
if (row.status === "consumed") return { ok: false, code: "ALREADY_CONSUMED" };
|
|
210
|
+
if (row.status !== "approved") return { ok: false, code: "NOT_APPROVED" };
|
|
211
|
+
if (row.argsDigest !== argsDigest || row.reviewDigest !== reviewDigest) {
|
|
212
|
+
await tx`UPDATE approvals.requests SET status='cancelled' WHERE id=${id}::uuid AND status='approved'`;
|
|
213
|
+
return { ok: false, code: row.argsDigest !== argsDigest ? "INPUT_CHANGED" : "REVIEW_CHANGED" };
|
|
214
|
+
}
|
|
215
|
+
await tx`UPDATE approvals.requests SET status='consumed', consumed_at=now() WHERE id=${id}::uuid AND status='approved'
|
|
216
|
+
AND requester_kind=${input.requester.kind} AND requester_ref=${input.requester.ref}
|
|
217
|
+
AND args_digest=${argsDigest} AND review_digest=${reviewDigest}`;
|
|
218
|
+
return { ok: true };
|
|
219
|
+
});
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
export const cancel = async (ids: string[], db?: SQL): Promise<void> => {
|
|
223
|
+
if (!ids.length) return;
|
|
224
|
+
await transaction(db, async (tx) => {
|
|
225
|
+
await tx`UPDATE approvals.requests SET status='cancelled' WHERE id=ANY(${toPgTextArray(ids)}::uuid[]) AND status IN ('pending','approved')`;
|
|
226
|
+
});
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
export const pruneApprovalRequests = async (before: Date): Promise<number> => {
|
|
230
|
+
let total = 0;
|
|
231
|
+
let deleted: number;
|
|
232
|
+
do {
|
|
233
|
+
const result = await sql`WITH expired AS (SELECT id FROM approvals.requests WHERE expires_at < ${before} LIMIT 5000)
|
|
234
|
+
DELETE FROM approvals.requests request USING expired WHERE request.id=expired.id`;
|
|
235
|
+
deleted = result.count;
|
|
236
|
+
total += deleted;
|
|
237
|
+
} while (deleted === 5000);
|
|
238
|
+
return total;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
export const approvals = { request, get, decide, consume, cancel };
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { sql } from "bun";
|
|
2
|
+
import { AI_WEBSITE_APPROVAL_TOOL, aiTurnAllowsRememberedApprovals } from "../../ai/approvals";
|
|
3
|
+
import type { AiTurnRunConfig } from "../../ai/types";
|
|
4
|
+
import type { RuntimeAppMeta } from "../../contracts/app";
|
|
5
|
+
import { APPROVALS_PAGE_SIZE, type ApprovalCounts, type PendingApproval } from "../../contracts/approvals";
|
|
6
|
+
import { CapabilityActionReviewSchema } from "../../contracts/capabilities";
|
|
7
|
+
import type { ApprovalAssurance } from ".";
|
|
8
|
+
import { decodeApprovalCursor, encodeApprovalCursor } from "./cursor";
|
|
9
|
+
|
|
10
|
+
export type PendingApprovalRow = {
|
|
11
|
+
id: string;
|
|
12
|
+
appId: string;
|
|
13
|
+
action: string;
|
|
14
|
+
argsDigest: string;
|
|
15
|
+
requesterAppId: string;
|
|
16
|
+
/** The chat title when Cloud asked; unlike `conversationTitle`, a later rename does not change it. */
|
|
17
|
+
requesterTitle: string | null;
|
|
18
|
+
assurance: ApprovalAssurance;
|
|
19
|
+
sentence: string;
|
|
20
|
+
details: unknown;
|
|
21
|
+
createdAt: Date;
|
|
22
|
+
expiresAt: Date;
|
|
23
|
+
cursorCreatedAt: string;
|
|
24
|
+
conversationShortId: string;
|
|
25
|
+
conversationTitle: string | null;
|
|
26
|
+
allowChat: boolean | null;
|
|
27
|
+
allowAlways: boolean;
|
|
28
|
+
rememberToolName: string | null;
|
|
29
|
+
toolName: string;
|
|
30
|
+
runConfig: AiTurnRunConfig | null;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Start with this person's indexed pending rows; a Studio page alone cannot continue a chat.
|
|
34
|
+
const openRequests = (approverUserId: string, id?: string) => sql`SELECT r.* FROM approvals.requests r
|
|
35
|
+
WHERE r.approver_user_id=${approverUserId}::uuid AND r.status='pending' AND r.expires_at > now()
|
|
36
|
+
AND (${id ?? null}::uuid IS NULL OR r.id=${id ?? null}::uuid)
|
|
37
|
+
AND EXISTS (SELECT 1 FROM ai.pending_actions p WHERE p.approval_id=r.id AND p.status='pending')`;
|
|
38
|
+
const matches = (appId?: string, link?: string) => sql`
|
|
39
|
+
(${appId ?? null}::text IS NULL OR r.app_id=${appId ?? null} OR r.requester_app_id=${appId ?? null})
|
|
40
|
+
AND (${link ?? null}::text IS NULL OR COALESCE(r.details->'links' @> jsonb_build_array(jsonb_build_object('href', ${link ?? null}::text)),false))`;
|
|
41
|
+
|
|
42
|
+
export const listPending = async (input: {
|
|
43
|
+
approverUserId: string;
|
|
44
|
+
id?: string;
|
|
45
|
+
appId?: string;
|
|
46
|
+
link?: string;
|
|
47
|
+
cursor?: string;
|
|
48
|
+
limit?: number;
|
|
49
|
+
}): Promise<{ items: PendingApprovalRow[]; nextCursor: string | null; count: number; elsewhere: number }> => {
|
|
50
|
+
const limit = input.limit ?? APPROVALS_PAGE_SIZE;
|
|
51
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > APPROVALS_PAGE_SIZE) throw new RangeError("Invalid approval page size.");
|
|
52
|
+
const cursor = input.cursor ? decodeApprovalCursor(input.cursor) : null;
|
|
53
|
+
if (input.cursor && !cursor) throw new RangeError("Invalid approval cursor.");
|
|
54
|
+
const [counts] = await sql<{ count: number; elsewhere: number }[]>`WITH open AS (${openRequests(input.approverUserId, input.id)})
|
|
55
|
+
SELECT count(*) FILTER (WHERE ${matches(input.appId, input.link)})::int AS count,
|
|
56
|
+
count(*) FILTER (WHERE NOT (${matches(input.appId, input.link)}))::int AS elsewhere FROM open r`;
|
|
57
|
+
const rows = await sql<PendingApprovalRow[]>`WITH open AS (${openRequests(input.approverUserId, input.id)})
|
|
58
|
+
SELECT r.id, r.app_id AS "appId", r.action, r.requester_app_id AS "requesterAppId", r.requester_title AS "requesterTitle", r.assurance, r.sentence, r.details, r.args_digest AS "argsDigest",
|
|
59
|
+
r.created_at AS "createdAt", r.expires_at AS "expiresAt",
|
|
60
|
+
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS "cursorCreatedAt",
|
|
61
|
+
question.short_id AS "conversationShortId", question.title AS "conversationTitle",
|
|
62
|
+
question.allow_chat AS "allowChat", question.allow_always AS "allowAlways",
|
|
63
|
+
question.remember_tool_name AS "rememberToolName", question.tool_name AS "toolName", question.run_config AS "runConfig"
|
|
64
|
+
FROM open r JOIN LATERAL (
|
|
65
|
+
SELECT c.short_id, c.title, p.allow_chat, p.allow_always, p.remember_tool_name, p.tool_name, t.run_config
|
|
66
|
+
FROM ai.pending_actions p JOIN ai.conversations c ON c.id=p.conversation_id JOIN ai.turns t ON t.id=p.turn_id
|
|
67
|
+
WHERE p.approval_id=r.id AND p.status='pending' ORDER BY p.created_at DESC, p.id DESC LIMIT 1
|
|
68
|
+
) question ON true
|
|
69
|
+
WHERE ${matches(input.appId, input.link)}
|
|
70
|
+
AND (${cursor?.id ?? null}::uuid IS NULL OR (r.created_at,r.id) < (${cursor?.createdAt ?? null}::timestamptz,${cursor?.id ?? null}::uuid))
|
|
71
|
+
ORDER BY r.created_at DESC,r.id DESC LIMIT ${limit + 1}`;
|
|
72
|
+
const items = rows.slice(0, limit);
|
|
73
|
+
const last = items.at(-1);
|
|
74
|
+
return {
|
|
75
|
+
items,
|
|
76
|
+
nextCursor: rows.length > limit && last ? encodeApprovalCursor({ createdAt: last.cursorCreatedAt, id: last.id }) : null,
|
|
77
|
+
count: counts?.count ?? 0,
|
|
78
|
+
elsewhere: counts?.elsewhere ?? 0,
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const countPending = async (input: { approverUserId: string }): Promise<ApprovalCounts> => {
|
|
83
|
+
const [row] = await sql<ApprovalCounts[]>`WITH open AS MATERIALIZED (${openRequests(input.approverUserId)}),
|
|
84
|
+
app_requests AS (SELECT DISTINCT r.id AS request_id, app.id AS app_id FROM open r
|
|
85
|
+
CROSS JOIN LATERAL unnest(ARRAY[r.app_id,r.requester_app_id]) app(id)),
|
|
86
|
+
app_counts AS (SELECT app_id AS id,count(*)::int AS count FROM app_requests GROUP BY app_id)
|
|
87
|
+
SELECT (SELECT count(*)::int FROM open) AS total,
|
|
88
|
+
COALESCE((SELECT jsonb_object_agg(id,count) FROM app_counts),'{}'::jsonb) AS apps`;
|
|
89
|
+
return row ?? { total: 0, apps: {} };
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export const presentPendingApproval = (
|
|
93
|
+
row: PendingApprovalRow,
|
|
94
|
+
apps: readonly Pick<RuntimeAppMeta, "id" | "name" | "icon">[],
|
|
95
|
+
): PendingApproval => {
|
|
96
|
+
const app = apps.find((entry) => entry.id === row.appId);
|
|
97
|
+
const review = CapabilityActionReviewSchema.nullable().safeParse(row.details);
|
|
98
|
+
const remember = row.assurance === "standard" && aiTurnAllowsRememberedApprovals(row.runConfig);
|
|
99
|
+
return {
|
|
100
|
+
id: row.id,
|
|
101
|
+
app: { id: row.appId, name: app?.name ?? row.appId, icon: app?.icon ?? null },
|
|
102
|
+
action: row.action,
|
|
103
|
+
assurance: row.assurance,
|
|
104
|
+
sentence: row.sentence,
|
|
105
|
+
review: review.success ? review.data : null,
|
|
106
|
+
requester: {
|
|
107
|
+
kind: "chat",
|
|
108
|
+
appId: row.requesterAppId,
|
|
109
|
+
title: row.conversationTitle,
|
|
110
|
+
href: `/app/assistant?conversation=${encodeURIComponent(row.conversationShortId)}`,
|
|
111
|
+
},
|
|
112
|
+
remember: {
|
|
113
|
+
chat: remember && (row.allowChat ?? row.allowAlways),
|
|
114
|
+
always: remember && row.allowAlways,
|
|
115
|
+
website: (row.rememberToolName ?? row.toolName) === AI_WEBSITE_APPROVAL_TOOL,
|
|
116
|
+
},
|
|
117
|
+
createdAt: row.createdAt.toISOString(),
|
|
118
|
+
expiresAt: row.expiresAt.toISOString(),
|
|
119
|
+
};
|
|
120
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { sql } from "bun";
|
|
2
|
+
|
|
3
|
+
export const migrateApprovals = async (): Promise<void> => {
|
|
4
|
+
await sql`CREATE SCHEMA IF NOT EXISTS approvals`.simple();
|
|
5
|
+
await sql`
|
|
6
|
+
CREATE TABLE IF NOT EXISTS approvals.requests (
|
|
7
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
8
|
+
app_id TEXT NOT NULL,
|
|
9
|
+
action TEXT NOT NULL,
|
|
10
|
+
approval_scope TEXT,
|
|
11
|
+
requester_kind TEXT NOT NULL CHECK (requester_kind IN ('assistant_turn','studio_app')),
|
|
12
|
+
requester_ref TEXT NOT NULL,
|
|
13
|
+
requester_app_id TEXT NOT NULL,
|
|
14
|
+
requester_title TEXT,
|
|
15
|
+
subject_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
16
|
+
approver_user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
17
|
+
assurance TEXT NOT NULL CHECK (assurance IN ('standard','confirm')),
|
|
18
|
+
sentence TEXT NOT NULL,
|
|
19
|
+
details JSONB,
|
|
20
|
+
args_digest TEXT NOT NULL,
|
|
21
|
+
review_digest TEXT NOT NULL,
|
|
22
|
+
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','approved','rejected','expired','cancelled','consumed')),
|
|
23
|
+
decided_via TEXT CHECK (decided_via IS NULL OR decided_via IN ('web_session','app_session','oauth','api_key','invocation','acp_bridge','cloud_login')),
|
|
24
|
+
decided_at TIMESTAMPTZ,
|
|
25
|
+
consumed_at TIMESTAMPTZ,
|
|
26
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
27
|
+
expires_at TIMESTAMPTZ NOT NULL,
|
|
28
|
+
UNIQUE (requester_kind, requester_ref)
|
|
29
|
+
)
|
|
30
|
+
`.simple();
|
|
31
|
+
await sql`DO $$ BEGIN
|
|
32
|
+
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid='approvals.requests'::regclass
|
|
33
|
+
AND conname='requests_decided_via_check' AND position('cloud_login' in pg_get_constraintdef(oid))=0) THEN
|
|
34
|
+
ALTER TABLE approvals.requests DROP CONSTRAINT requests_decided_via_check;
|
|
35
|
+
ALTER TABLE approvals.requests ADD CONSTRAINT requests_decided_via_check
|
|
36
|
+
CHECK (decided_via IS NULL OR decided_via IN ('web_session','app_session','oauth','api_key','invocation','acp_bridge','cloud_login'));
|
|
37
|
+
END IF;
|
|
38
|
+
END $$`.simple();
|
|
39
|
+
await sql`CREATE INDEX IF NOT EXISTS approvals_requests_pending ON approvals.requests(approver_user_id, created_at) WHERE status='pending'`.simple();
|
|
40
|
+
await sql`CREATE INDEX IF NOT EXISTS approvals_requests_expiry ON approvals.requests(expires_at)`.simple();
|
|
41
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { AI_INFERENCE_SCOPE, type User } from "../../contracts/shared";
|
|
2
|
+
import { isAccountExpired } from "../account-model";
|
|
3
|
+
import { serviceAccountCredentials } from "../service-account-credentials";
|
|
4
|
+
|
|
5
|
+
export const authenticateModelsOnlyCredential = async (
|
|
6
|
+
token: string | null | undefined,
|
|
7
|
+
): Promise<{ user: User; credentialId: string } | null> => {
|
|
8
|
+
if (!token || !serviceAccountCredentials.isApiToken(token)) return null;
|
|
9
|
+
const authenticated = await serviceAccountCredentials.authenticateApiToken(token);
|
|
10
|
+
if (!authenticated) return null;
|
|
11
|
+
const { credential, serviceAccount, delegatedUser } = authenticated;
|
|
12
|
+
if (
|
|
13
|
+
credential.kind !== "api_token" ||
|
|
14
|
+
credential.status !== "active" ||
|
|
15
|
+
credential.serviceAccountId !== serviceAccount.id ||
|
|
16
|
+
serviceAccount.status !== "active" ||
|
|
17
|
+
serviceAccount.kind !== "user_delegated" ||
|
|
18
|
+
!delegatedUser ||
|
|
19
|
+
serviceAccount.delegatedUserId !== delegatedUser.id ||
|
|
20
|
+
!credential.scopes.includes(AI_INFERENCE_SCOPE) ||
|
|
21
|
+
isAccountExpired(delegatedUser.accountExpires)
|
|
22
|
+
)
|
|
23
|
+
return null;
|
|
24
|
+
return { user: delegatedUser, credentialId: credential.id };
|
|
25
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import sanitizeHtml from "sanitize-html";
|
|
2
|
-
import {
|
|
2
|
+
import { EMAIL_HTML_SANITIZE_OPTIONS } from "../../shared/email-html";
|
|
3
3
|
import * as settings from "../settings";
|
|
4
4
|
import { coreSettings } from "../settings/api";
|
|
5
5
|
|
|
@@ -8,16 +8,29 @@ const sanitizeContent = (content: string): string => sanitizeHtml(content, { all
|
|
|
8
8
|
// sanitize-html decodes entities before escaping &, < and > again. Decode only
|
|
9
9
|
// those escapes once, so literal entity text is not recursively interpreted.
|
|
10
10
|
const htmlText = (html: string): string =>
|
|
11
|
-
sanitizeContent(html.replace(/<br\b[^>]*>|<\/(?:p|div|h[1-6]|li|tr|td|th|ul|ol|table)>/gi, " "))
|
|
11
|
+
sanitizeContent(html.replace(/<br\b[^>]*>|<\/(?:p|pre|blockquote|div|h[1-6]|li|tr|td|th|ul|ol|table)>/gi, " "))
|
|
12
12
|
.replace(/&(amp|lt|gt);/g, (entity) => (entity === "&" ? "&" : entity === "<" ? "<" : ">"))
|
|
13
13
|
.replace(/\s+/g, " ")
|
|
14
14
|
.trim();
|
|
15
15
|
|
|
16
|
+
// The frame has no stylesheet, so a `pre` would keep the client default `white-space: pre` and push long
|
|
17
|
+
// lines past the frame. The sender's own style comes last and still wins.
|
|
18
|
+
const frameHtmlOptions = {
|
|
19
|
+
...EMAIL_HTML_SANITIZE_OPTIONS,
|
|
20
|
+
transformTags: {
|
|
21
|
+
...EMAIL_HTML_SANITIZE_OPTIONS.transformTags,
|
|
22
|
+
pre: (tagName, attribs) => ({
|
|
23
|
+
tagName,
|
|
24
|
+
attribs: { ...attribs, style: `white-space:pre-wrap;overflow-wrap:break-word;${attribs.style ?? ""}` },
|
|
25
|
+
}),
|
|
26
|
+
},
|
|
27
|
+
} satisfies sanitizeHtml.IOptions;
|
|
28
|
+
|
|
16
29
|
export const prepareNotificationEmail = async (opts: { content?: string; rawHtml?: string }): Promise<{ html: string; text: string }> => {
|
|
17
30
|
const rawAppUrl = await settings.get<string>("app.url");
|
|
18
31
|
const appUrl = rawAppUrl.startsWith("http") ? rawAppUrl : `https://${rawAppUrl}`;
|
|
19
32
|
const appName = await settings.get<string>("app.name");
|
|
20
|
-
const body = opts.rawHtml ?
|
|
33
|
+
const body = opts.rawHtml ? sanitizeHtml(opts.rawHtml, frameHtmlOptions) : opts.content ? `<p>${sanitizeContent(opts.content)}</p>` : "";
|
|
21
34
|
return { html: await buildHtml(appUrl, appName, body), text: opts.content ? sanitizeContent(opts.content) : htmlText(body) };
|
|
22
35
|
};
|
|
23
36
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { crypto, err, fail, ok, type PageParams, type Paginated, type Result } from "@k2b/stdlib";
|
|
3
3
|
import { type SQL, sql } from "bun";
|
|
4
|
-
import type
|
|
4
|
+
import { AI_INFERENCE_SCOPE, type User } from "../contracts/shared";
|
|
5
5
|
import { accounts } from "./accounts";
|
|
6
6
|
import { audit } from "./audit";
|
|
7
7
|
import { isUniqueViolation, toPgTextArray } from "./postgres";
|
|
@@ -199,12 +199,16 @@ const generateTokenParts = (): { tokenPrefix: string; secret: string; token: str
|
|
|
199
199
|
};
|
|
200
200
|
|
|
201
201
|
// API token secrets carry 256 bits of entropy, so a fast digest is sufficient and avoids password-KDF cost per request.
|
|
202
|
-
|
|
202
|
+
// Domain-separate models-only keys so older platforms fail verification instead of granting full authority.
|
|
203
|
+
const hashApiTokenSecret = (secret: string, scopes: readonly string[]): string =>
|
|
204
|
+
`sha256:${createHash("sha256")
|
|
205
|
+
.update(scopes.includes(AI_INFERENCE_SCOPE) ? `${AI_INFERENCE_SCOPE}:${secret}` : secret)
|
|
206
|
+
.digest("hex")}`;
|
|
203
207
|
|
|
204
|
-
const verifyApiTokenSecret = async (secret: string, storedHash: string): Promise<boolean> => {
|
|
208
|
+
const verifyApiTokenSecret = async (secret: string, storedHash: string, scopes: readonly string[]): Promise<boolean> => {
|
|
205
209
|
const match = storedHash.match(SECRET_HASH_PATTERN);
|
|
206
210
|
if (match) {
|
|
207
|
-
const actual = Buffer.from(hashApiTokenSecret(secret).slice("sha256:".length), "hex");
|
|
211
|
+
const actual = Buffer.from(hashApiTokenSecret(secret, scopes).slice("sha256:".length), "hex");
|
|
208
212
|
const expected = Buffer.from(match[1]!, "hex");
|
|
209
213
|
return timingSafeEqual(actual, expected);
|
|
210
214
|
}
|
|
@@ -338,7 +342,7 @@ const insertApiToken = async (
|
|
|
338
342
|
}
|
|
339
343
|
|
|
340
344
|
const parts = await generateUniqueTokenParts();
|
|
341
|
-
const secretHash = hashApiTokenSecret(parts.secret);
|
|
345
|
+
const secretHash = hashApiTokenSecret(parts.secret, params.scopes ?? []);
|
|
342
346
|
|
|
343
347
|
try {
|
|
344
348
|
const [row] = await db<DbCredentialRow[]>`
|
|
@@ -371,16 +375,20 @@ const insertApiToken = async (
|
|
|
371
375
|
}
|
|
372
376
|
};
|
|
373
377
|
|
|
374
|
-
export const createApiToken = (params: {
|
|
378
|
+
export const createApiToken = async (params: {
|
|
375
379
|
serviceAccountId: string;
|
|
376
380
|
name: string;
|
|
377
381
|
createdBy?: string | null;
|
|
378
382
|
expiresAt?: string | null;
|
|
379
383
|
scopes?: string[];
|
|
380
|
-
}): Promise<Result<{ credential: ServiceAccountCredential; token: string }>> =>
|
|
384
|
+
}): Promise<Result<{ credential: ServiceAccountCredential; token: string }>> => {
|
|
385
|
+
if (params.scopes?.includes(AI_INFERENCE_SCOPE)) return fail(err.badInput("ai:inference is reserved for models-only personal API keys"));
|
|
386
|
+
return insertApiToken(sql, params);
|
|
387
|
+
};
|
|
381
388
|
|
|
382
389
|
export const createUserApiToken = async (params: {
|
|
383
390
|
user: User;
|
|
391
|
+
access?: "full" | "models";
|
|
384
392
|
name: string;
|
|
385
393
|
expiresAt?: string | null;
|
|
386
394
|
}): Promise<Result<{ credential: ServiceAccountCredential; token: string }>> => {
|
|
@@ -396,6 +404,7 @@ export const createUserApiToken = async (params: {
|
|
|
396
404
|
name: params.name,
|
|
397
405
|
expiresAt: params.expiresAt,
|
|
398
406
|
createdBy: params.user.id,
|
|
407
|
+
scopes: params.access === "models" ? [AI_INFERENCE_SCOPE] : [],
|
|
399
408
|
});
|
|
400
409
|
|
|
401
410
|
return audit.recordResult({
|
|
@@ -404,6 +413,7 @@ export const createUserApiToken = async (params: {
|
|
|
404
413
|
target: { type: "service_account_credential", id: result.ok ? result.data.credential.id : null, label: params.name },
|
|
405
414
|
metadata: {
|
|
406
415
|
serviceAccountId: serviceAccountResult.data.id,
|
|
416
|
+
access: params.access ?? "full",
|
|
407
417
|
kind: "api_token",
|
|
408
418
|
expiresAt: params.expiresAt ?? null,
|
|
409
419
|
},
|
|
@@ -420,6 +430,7 @@ export const createResourceApiToken = async (params: {
|
|
|
420
430
|
expiresAt?: string | null;
|
|
421
431
|
scopes?: string[];
|
|
422
432
|
}): Promise<Result<{ credential: ServiceAccountCredential; token: string }>> => {
|
|
433
|
+
if (params.scopes?.includes(AI_INFERENCE_SCOPE)) return fail(err.badInput("ai:inference is reserved for models-only personal API keys"));
|
|
423
434
|
const serviceAccount = await serviceAccounts.get({ id: params.serviceAccountId });
|
|
424
435
|
if (!serviceAccount || serviceAccount.kind !== "resource_bound") return fail(err.notFound("Resource service account"));
|
|
425
436
|
if (serviceAccount.status !== "active") return fail(err.badInput("Resource service account is disabled"));
|
|
@@ -460,6 +471,7 @@ export const createStandaloneApiToken = async (params: {
|
|
|
460
471
|
expiresAt?: string | null;
|
|
461
472
|
scopes?: string[];
|
|
462
473
|
}): Promise<Result<{ credential: ServiceAccountCredential; token: string }>> => {
|
|
474
|
+
if (params.scopes?.includes(AI_INFERENCE_SCOPE)) return fail(err.badInput("ai:inference is reserved for models-only personal API keys"));
|
|
463
475
|
const serviceAccount = await serviceAccounts.get({ id: params.serviceAccountId });
|
|
464
476
|
if (!serviceAccount || !isStandaloneServiceAccountKind(serviceAccount.kind)) return fail(err.notFound("Service account"));
|
|
465
477
|
if (serviceAccount.status !== "active") return fail(err.badInput("Service account is disabled"));
|
|
@@ -780,7 +792,7 @@ const recordSuccessfulAuthentication = async (params: {
|
|
|
780
792
|
serviceAccount: ServiceAccount;
|
|
781
793
|
delegatedUser: User | null;
|
|
782
794
|
}): Promise<void> => {
|
|
783
|
-
const upgradedSecretHash = SECRET_HASH_PATTERN.test(params.row.secret_hash) ? null : hashApiTokenSecret(params.secret);
|
|
795
|
+
const upgradedSecretHash = SECRET_HASH_PATTERN.test(params.row.secret_hash) ? null : hashApiTokenSecret(params.secret, params.row.scopes);
|
|
784
796
|
if (!upgradedSecretHash && !params.row.activity_due) return;
|
|
785
797
|
|
|
786
798
|
if (upgradedSecretHash) {
|
|
@@ -836,10 +848,10 @@ const recordSuccessfulAuthentication = async (params: {
|
|
|
836
848
|
const pendingSecretVerifications = new Map<string, Promise<boolean>>();
|
|
837
849
|
|
|
838
850
|
const verifyApiTokenSecretOnce = async (secret: string, row: DbCredentialWithSecretRow): Promise<boolean> => {
|
|
839
|
-
const key = `${row.id}:${row.secret_hash}:${hashApiTokenSecret(secret)}`;
|
|
851
|
+
const key = `${row.id}:${row.secret_hash}:${hashApiTokenSecret(secret, row.scopes)}`;
|
|
840
852
|
let pending = pendingSecretVerifications.get(key);
|
|
841
853
|
if (!pending) {
|
|
842
|
-
pending = verifyApiTokenSecret(secret, row.secret_hash);
|
|
854
|
+
pending = verifyApiTokenSecret(secret, row.secret_hash, row.scopes);
|
|
843
855
|
pendingSecretVerifications.set(key, pending);
|
|
844
856
|
}
|
|
845
857
|
try {
|
|
@@ -296,6 +296,13 @@ export const CORE_SETTINGS = {
|
|
|
296
296
|
placeholder: "Keep answers concise. The current user is {{ user.displayName }} and today is {{ today }}.",
|
|
297
297
|
templateVars: ["user.displayName", "user.uid", "user.mail", "appId", "now", "today", "time"],
|
|
298
298
|
},
|
|
299
|
+
"ai.local_client_instructions": {
|
|
300
|
+
kind: "text",
|
|
301
|
+
label: "Instructions for local clients",
|
|
302
|
+
default: "",
|
|
303
|
+
description:
|
|
304
|
+
"Added to turns from local clients such as cld after the organization instructions. Leave empty to use the built-in text, which improves with each release.",
|
|
305
|
+
},
|
|
299
306
|
"ai.compaction_instructions": {
|
|
300
307
|
kind: "text",
|
|
301
308
|
label: "Additional Compaction Instructions",
|
|
@@ -328,6 +335,26 @@ export const CORE_SETTINGS = {
|
|
|
328
335
|
description: "Firecrawl API key used by Cloud AI web_search and web_extract tools.",
|
|
329
336
|
placeholder: "fc-...",
|
|
330
337
|
},
|
|
338
|
+
"ai.openai_api_enabled": {
|
|
339
|
+
kind: "boolean",
|
|
340
|
+
label: "OpenAI-compatible API",
|
|
341
|
+
default: false,
|
|
342
|
+
description:
|
|
343
|
+
"Let people create API keys with the access “AI models only” and call the Assistant models they may use. Checked on every request: turning this off stops existing keys too. Models without reference prices have no usage limit; only the rate limit and the concurrent request limit protect them.",
|
|
344
|
+
},
|
|
345
|
+
// Default 4: one process runs at most 8 chat turns at once (AI_TURN_WORKER_CONCURRENCY in ai/runtime.ts), and this
|
|
346
|
+
// synchronous API bypasses that queue, so one person may hold at most half of it. Typical clients need 1-2 (one
|
|
347
|
+
// request after another plus a connection test); 4 leaves room for a second client without letting a script in a
|
|
348
|
+
// loop crowd out everyone else. Operators with more provider capacity raise it.
|
|
349
|
+
"ai.openai_api_max_concurrent_requests": {
|
|
350
|
+
kind: "number",
|
|
351
|
+
label: "OpenAI API concurrent requests per person",
|
|
352
|
+
integer: true,
|
|
353
|
+
min: 1,
|
|
354
|
+
max: 64,
|
|
355
|
+
default: 4,
|
|
356
|
+
description: "Maximum simultaneous OpenAI-compatible API requests per person across all Cloud instances.",
|
|
357
|
+
},
|
|
331
358
|
// ── PDF rendering ─────────────────────────────────────────────────────
|
|
332
359
|
"gotenberg.url": {
|
|
333
360
|
kind: "url",
|