@k2b/cloud 0.42.0 → 0.44.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 +13 -5
- package/scripts/app-favicon.ts +7 -6
- package/scripts/build.ts +3 -0
- package/scripts/installable-assets.ts +61 -0
- package/scripts/preload.ts +3 -0
- package/src/_internal/app-access-path.ts +12 -0
- package/src/_internal/app-access-request.ts +13 -0
- package/src/_internal/app-access-resources.ts +141 -0
- package/src/_internal/app-event-catalog.ts +40 -0
- package/src/_internal/app-event-schemas.ts +127 -0
- package/src/_internal/application-package.ts +40 -0
- package/src/_internal/built-in-app-ids.json +27 -0
- package/src/_internal/capabilities.ts +185 -12
- package/src/_internal/capability-origin.ts +4 -0
- package/src/_internal/capability-streams.ts +10 -5
- package/src/_internal/define-app.ts +159 -45
- package/src/_internal/installable-assets.ts +90 -0
- package/src/_internal/installable-head.ts +22 -0
- package/src/_internal/object-store-area.ts +9 -0
- package/src/_internal/object-store-config.ts +101 -0
- package/src/_internal/object-store-key.ts +14 -0
- package/src/_internal/page-responses.ts +34 -3
- package/src/_internal/process-identity.ts +7 -2
- package/src/_internal/registry-validation.ts +15 -1
- package/src/_internal/registry.ts +44 -1
- package/src/_internal/runtime-context.ts +9 -1
- package/src/_internal/runtime-watcher.ts +8 -0
- package/src/_internal/status-preserving-ssr.ts +12 -1
- package/src/access/AppAccessEditor.browser-fixture.ts +429 -0
- package/src/access/AppAccessEditor.browser-frame.tsx +68 -0
- package/src/access/AppAccessEditor.browser-harness.tsx +65 -0
- package/src/access/AppAccessEditor.tsx +380 -0
- package/src/access/PermissionEditor.browser-harness.tsx +22 -2
- package/src/access/PermissionEditor.tsx +386 -77
- package/src/access/PrincipalAvatar.tsx +63 -0
- package/src/access/PrincipalPicker.tsx +29 -8
- package/src/access/ResourceApiKeys.tsx +3 -4
- package/src/access/app-access-adapter.ts +51 -0
- package/src/access/app-access-messages.ts +172 -0
- package/src/access/messages.ts +14 -0
- package/src/access/ui.ts +4 -1
- package/src/account/Avatar.tsx +21 -3
- package/src/account/ProfilePictureDialog.tsx +699 -0
- package/src/account/profile-picture-messages.ts +269 -0
- package/src/account/ui.ts +8 -1
- package/src/ai/admin.ts +3 -0
- package/src/ai/app-frame.ts +12 -0
- package/src/ai/application-tasks.ts +101 -0
- package/src/ai/assistant-models.ts +126 -64
- package/src/ai/capabilities.ts +19 -4
- package/src/ai/capability-execution.ts +5 -2
- package/src/ai/chart-block.ts +63 -30
- package/src/ai/chat/chart-block.tsx +12 -3
- package/src/ai/chat/composer-adapter.ts +8 -7
- package/src/ai/chat/message-actions.tsx +1 -1
- package/src/ai/chat/message-utils.ts +2 -2
- package/src/ai/chat/presentation.tsx +22 -1
- package/src/ai/chat-quotas.ts +5 -4
- package/src/ai/chat-tasks.ts +2 -2
- package/src/ai/client/controller.ts +11 -10
- package/src/ai/client/projection.ts +3 -3
- package/src/ai/code-capability-routes.ts +1 -0
- package/src/ai/compaction.ts +3 -3
- package/src/ai/credentials.ts +13 -114
- package/src/ai/default-tool-contracts.ts +4 -3
- package/src/ai/dictation-runtime.ts +4 -4
- package/src/ai/enrich.ts +2 -2
- package/src/ai/executor.ts +38 -27
- package/src/ai/http.ts +17 -29
- package/src/ai/index.ts +11 -5
- package/src/ai/inference-calls.ts +6 -4
- package/src/ai/maintenance.ts +1 -1
- package/src/ai/memory-learning-runs.ts +5 -5
- package/src/ai/memory-learning.ts +3 -3
- package/src/ai/message-queue.ts +1 -2
- package/src/ai/migrate.ts +75 -42
- package/src/ai/model-access-migrate.ts +3 -17
- package/src/ai/model-access.ts +44 -85
- package/src/ai/model-card.tsx +134 -0
- package/src/ai/model-catalog.ts +359 -0
- package/src/ai/model-chooser-messages.ts +88 -0
- package/src/ai/model-chooser.tsx +577 -0
- package/src/ai/model-delete.ts +234 -0
- package/src/ai/model-fixture.ts +39 -0
- package/src/ai/model-icon.tsx +36 -0
- package/src/ai/model-overview.ts +53 -0
- package/src/ai/model-usage.ts +45 -0
- package/src/ai/openai-api-messages.ts +0 -4
- package/src/ai/openai-api-slots.ts +10 -2
- package/src/ai/openai-api.ts +83 -16
- package/src/ai/personal-agent.ts +1 -1
- package/src/ai/prefs.ts +12 -10
- package/src/ai/projects-routes.ts +2 -1
- package/src/ai/projects.ts +13 -12
- package/src/ai/protocol.ts +2 -2
- package/src/ai/provider-connections.ts +161 -0
- package/src/ai/provider-errors.ts +138 -0
- package/src/ai/provider-fetch.ts +4 -0
- package/src/ai/provider-retry.ts +1 -1
- package/src/ai/provider.ts +2 -1
- package/src/ai/quota-limits.ts +36 -0
- package/src/ai/quota-provider.ts +11 -2
- package/src/ai/quota-report.ts +7 -4
- package/src/ai/quotas-migrate.ts +1 -0
- package/src/ai/quotas.ts +19 -8
- package/src/ai/recent-resources.ts +6 -0
- package/src/ai/request-headers.ts +14 -87
- package/src/ai/resource-refs.ts +6 -2
- package/src/ai/routes.ts +17 -18
- package/src/ai/runtime.ts +50 -22
- package/src/ai/settings.ts +93 -580
- package/src/ai/store.ts +45 -23
- package/src/ai/stream.ts +1 -1
- package/src/ai/structured-runs.ts +2 -2
- package/src/ai/structured.ts +29 -46
- package/src/ai/task-contracts.ts +25 -5
- package/src/ai/task-execution.ts +15 -6
- package/src/ai/task-model.ts +9 -0
- package/src/ai/transcription.ts +37 -21
- package/src/ai/turn-failure.ts +24 -2
- package/src/ai/types.ts +50 -62
- package/src/ai/ui.tsx +13 -0
- package/src/ai/usage.ts +8 -8
- package/src/ai/validate.ts +8 -7
- package/src/ai/view-block.ts +2 -2
- package/src/api/accounts-entities.ts +40 -16
- package/src/api/admin-ai-quotas.ts +34 -56
- package/src/api/admin-ai-usage.ts +1 -1
- package/src/api/admin-ai.ts +173 -0
- package/src/api/admin-core-settings.ts +68 -244
- package/src/api/admin-directory-shares.ts +55 -0
- package/src/api/admin-identity.ts +81 -2
- package/src/api/app-access.ts +85 -0
- package/src/api/app-approval.ts +3 -1
- package/src/api/app-event-delivery.ts +51 -0
- package/src/api/app-events.ts +123 -0
- package/src/api/app-resource-access.ts +48 -0
- package/src/api/auth/account-mismatch.ts +24 -0
- package/src/api/auth.ts +26 -3
- package/src/api/capabilities.ts +127 -15
- package/src/api/capability-streams.ts +2 -5
- package/src/api/identity-child-invocation.ts +145 -0
- package/src/api/index.ts +11 -2
- package/src/api/mcp.ts +180 -5
- package/src/api/me.ts +38 -17
- package/src/api/pwa-phone.ts +87 -48
- package/src/api/pwa.ts +34 -8
- package/src/api/resource-links.ts +83 -6
- package/src/api/resource-resolve.ts +50 -0
- package/src/api/search/schemas.ts +3 -1
- package/src/api/search.ts +9 -7
- package/src/api/widgets.ts +1 -0
- package/src/app-pwa/authority.ts +75 -0
- package/src/app-pwa/contracts.ts +177 -0
- package/src/app-pwa/core-query-error.ts +20 -0
- package/src/app-pwa/index.ts +23 -0
- package/src/app-pwa/protocol.ts +27 -0
- package/src/app-pwa/schema.ts +59 -0
- package/src/app-pwa/service.ts +861 -0
- package/src/app-pwa/transaction.ts +338 -0
- package/src/app-pwa/transport-input.ts +57 -0
- package/src/app-pwa/transport.ts +235 -0
- package/src/approvals/messages.ts +2 -2
- package/src/browser/app-access-recovery.ts +44 -0
- package/src/browser/app-session.ts +14 -3
- package/src/browser/cloud-id.ts +86 -0
- package/src/browser/live-websocket.ts +5 -0
- package/src/browser/pwa-drafts.ts +57 -0
- package/src/browser/resource-clipboard.ts +4 -0
- package/src/browser/resource-links.ts +52 -0
- package/src/browser/resource-picker.tsx +3 -2
- package/src/capabilities/calendar-sources.ts +302 -0
- package/src/capabilities/executions.ts +10 -3
- package/src/capabilities/migrate.ts +4 -0
- package/src/capabilities/nested.ts +43 -0
- package/src/capabilities/server-invoke.ts +202 -0
- package/src/capabilities/server.ts +26 -187
- package/src/capabilities/testing.ts +5 -2
- package/src/cli/access.ts +6 -5
- package/src/cli/account.ts +57 -0
- package/src/cli/admin/agents.ts +66 -0
- package/src/cli/admin/ai-models.ts +199 -0
- package/src/cli/admin/ai-quotas.ts +6 -102
- package/src/cli/admin/ai-usage.ts +4 -4
- package/src/cli/admin/app-credentials.ts +5 -3
- package/src/cli/admin/index.ts +7 -3
- package/src/cli/app-access.ts +60 -0
- package/src/cli/apps.ts +89 -26
- package/src/cli/avatar.ts +64 -0
- package/src/cli/capabilities.ts +22 -1
- package/src/cli/events.ts +320 -0
- package/src/cli/index.ts +1 -1
- package/src/config/env.ts +40 -0
- package/src/contracts/app-access.ts +52 -5
- package/src/contracts/app-approval.ts +8 -1
- package/src/contracts/app-details.ts +89 -0
- package/src/contracts/app-events.ts +51 -0
- package/src/contracts/app.ts +24 -5
- package/src/contracts/calendar-source.ts +274 -0
- package/src/contracts/calendar-view.ts +35 -0
- package/src/contracts/capabilities.ts +164 -3
- package/src/contracts/geo-admin.ts +2 -0
- package/src/contracts/geo-route.ts +0 -11
- package/src/contracts/geo.ts +14 -2
- package/src/contracts/index.ts +6 -0
- package/src/contracts/installable.ts +40 -0
- package/src/contracts/notification-types.ts +36 -9
- package/src/contracts/profile.ts +32 -0
- package/src/contracts/pwa-builds.ts +14 -0
- package/src/contracts/pwa-location.ts +38 -0
- package/src/contracts/pwa.ts +24 -3
- package/src/contracts/registry.ts +8 -0
- package/src/contracts/resource-clipboard.ts +35 -0
- package/src/contracts/resource-pages.ts +140 -0
- package/src/contracts/resource-resolution.ts +13 -0
- package/src/contracts/shared.ts +4 -0
- package/src/contracts/user-notifications.ts +2 -0
- package/src/events/app-event-delivery.ts +100 -0
- package/src/events/app-events.testing.ts +65 -0
- package/src/events/app-events.ts +134 -0
- package/src/events/core-user-created.ts +24 -0
- package/src/events/index.ts +9 -0
- package/src/events/testing.ts +2 -0
- package/src/index.ts +2 -1
- package/src/server/api/respond.ts +8 -3
- package/src/server/index.ts +1 -0
- package/src/server/middleware/app-access.ts +7 -6
- package/src/server/middleware/auth.ts +168 -66
- package/src/server/middleware/index.ts +1 -0
- package/src/server/middleware/invocation.ts +4 -2
- package/src/server/middleware/workload.ts +3 -3
- package/src/server/oauth-scope.ts +10 -0
- package/src/server/public-storage.ts +106 -0
- package/src/server/services/access.ts +8 -0
- package/src/services/accounts/app.ts +35 -3
- package/src/services/accounts/avatar.ts +342 -35
- package/src/services/accounts/base-group.ts +4 -5
- package/src/services/accounts/directory-shares.ts +157 -0
- package/src/services/accounts/entities.ts +50 -6
- package/src/services/accounts/groups.ts +3 -1
- package/src/services/accounts/identities.ts +64 -12
- package/src/services/accounts/local-groups.ts +1 -1
- package/src/services/accounts/users.ts +23 -15
- package/src/services/app-access-management.ts +519 -0
- package/src/services/app-access.ts +25 -7
- package/src/services/app-approval.ts +15 -4
- package/src/services/app-details.ts +224 -0
- package/src/services/app-discovery.ts +2 -1
- package/src/services/app-event-shapes.ts +139 -0
- package/src/services/app-events/accept.ts +116 -0
- package/src/services/app-events/admin.ts +250 -0
- package/src/services/app-events/declarations.ts +187 -0
- package/src/services/app-events/deliver.ts +218 -0
- package/src/services/app-events.ts +92 -0
- package/src/services/config-conflict.ts +11 -0
- package/src/services/core-user-events.ts +1 -0
- package/src/services/freeipa-config.ts +9 -0
- package/src/services/geo/admin.ts +4 -1
- package/src/services/geo/ledger.ts +47 -14
- package/src/services/geo/route-chain.ts +40 -18
- package/src/services/geo/route-estimate.ts +19 -0
- package/src/services/geo/route-plan.ts +0 -2
- package/src/services/geo/route-service.ts +57 -39
- package/src/services/geo/service.ts +51 -25
- package/src/services/identity/core-origin.ts +12 -0
- package/src/services/identity/index.ts +2 -1
- package/src/services/identity/invocation-actor.ts +50 -2
- package/src/services/identity/invocation-authority.ts +38 -1
- package/src/services/identity/invocation-token.ts +255 -50
- package/src/services/identity/issue-invocation.ts +9 -1
- package/src/services/identity/models-only-auth.ts +13 -4
- package/src/services/identity/nested-invocation.ts +128 -0
- package/src/services/image-metadata.ts +65 -1
- package/src/services/index.ts +24 -1
- package/src/services/ipa/sync.ts +6 -2
- package/src/services/ipa/users.ts +4 -1
- package/src/services/mandates/policy.ts +19 -12
- package/src/services/notifications/browser.ts +4 -0
- package/src/services/notifications/catalog.ts +5 -2
- package/src/services/notifications/platform.ts +7 -2
- package/src/services/notifications/user.ts +18 -7
- package/src/services/oauth-tokens.ts +28 -2
- package/src/services/object-store.ts +217 -0
- package/src/services/providers/local/users.ts +5 -0
- package/src/services/pwa-devices.ts +147 -37
- package/src/services/service-account-credentials.ts +5 -2
- package/src/services/session/index.ts +14 -0
- package/src/services/settings/core-settings.ts +91 -47
- package/src/services/settings/index.ts +8 -0
- package/src/services/settings/versions.ts +26 -0
- package/src/shared/ai-models.ts +274 -0
- package/src/shared/ai-quotas.ts +2 -2
- package/src/shared/ai-usage.ts +2 -2
- package/src/shared/index.ts +2 -0
- package/src/shared-text/documents.ts +25 -8
- package/src/shared-text/log.ts +1 -1
- package/src/shared-text/text-diff.ts +75 -0
- package/src/ssr/AdminArea.tsx +131 -0
- package/src/ssr/AdminLayout.tsx +11 -3
- package/src/ssr/AdminSidebar.tsx +7 -7
- package/src/ssr/AppAccessDenied.tsx +114 -0
- package/src/ssr/AppBadge.tsx +14 -0
- package/src/ssr/AppLaunchpadPanel.tsx +1 -1
- package/src/ssr/AppPwaRuntime.island.tsx +82 -0
- package/src/ssr/CloudShell.island.tsx +25 -4
- package/src/ssr/Layout.tsx +15 -7
- package/src/ssr/PwaLayout.tsx +73 -8
- package/src/ssr/PwaPartCount.island.tsx +23 -0
- package/src/ssr/PwaRuntime.island.tsx +85 -6
- package/src/ssr/SettingsSaveOutcome.tsx +58 -0
- package/src/ssr/SettingsSaveStatus.island.tsx +16 -0
- package/src/ssr/ShellRuntime.island.tsx +5 -0
- package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
- package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +1 -1
- package/src/ssr/admin-active-link.ts +16 -0
- package/src/ssr/admin-navigation.ts +26 -20
- package/src/ssr/app-badges.ts +31 -7
- package/src/ssr/app-pwa-document.tsx +16 -0
- package/src/ssr/app-pwa-messages.ts +17 -0
- package/src/ssr/index.ts +8 -0
- package/src/ssr/layout-context.ts +1 -1
- package/src/ssr/page-trail-runtime.ts +3 -1
- package/src/ssr/platform-messages.ts +9 -1
- package/src/ssr/pwa-messages.ts +10 -2
- package/src/ssr/rail-navigation.ts +5 -1
- package/src/ssr/settings-draft.ts +153 -0
- package/src/ssr/shell-data.tsx +2 -0
- package/src/styles/global.css +1 -0
- package/src/styles/profile-picture.css +270 -0
- package/src/styles/utilities-navigation.css +6 -0
- package/src/workflows/ai/policy.ts +49 -0
- package/src/workflows/ai/runtime.ts +131 -21
- package/src/workflows/ai/store.ts +283 -35
- package/src/workflows/ai/types.ts +8 -2
- package/src/workflows/ai-actions.ts +148 -120
- package/src/workflows/authoring.ts +31 -0
- package/src/workflows/contracts.ts +1 -0
- package/src/workflows/definition.ts +2 -0
- package/src/workflows/editor.ts +15 -0
- package/src/workflows/index.ts +2 -1
- package/src/workflows/language/compiler.ts +34 -3
- package/src/workflows/runtime/executor.ts +132 -33
- package/src/workflows/runtime/index.ts +2 -0
- package/src/workflows/runtime/ports.ts +5 -1
- package/src/workflows/store/actions.ts +1 -1
- package/src/workflows/store/definitions.ts +5 -0
- package/src/workflows/store/runs.ts +5 -3
- package/src/workflows/store/worker.ts +7 -2
- package/src/ai/model-pricing.ts +0 -43
- package/src/ai/model-request-settings.ts +0 -98
- package/src/ai/settings-messages.ts +0 -129
- package/src/shared/ai-model-request-settings.ts +0 -21
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { sql } from "bun";
|
|
2
2
|
import type { MutationResult, UserProfile } from "../../../contracts/shared";
|
|
3
|
+
import { coreUserEventPublisher } from "../../../events/core-user-created";
|
|
3
4
|
import { isAccountCategoryAllowed } from "../../account-category-policy";
|
|
4
5
|
import { writeDeletedAccountAudit } from "../../account-lifecycle/audit";
|
|
5
6
|
import { emailAlreadyUsed, findAccountsByEmail, lockAccountEmail, normalizeAccountEmail } from "../../accounts/email-write";
|
|
@@ -96,6 +97,10 @@ export const create = async (params: {
|
|
|
96
97
|
)
|
|
97
98
|
RETURNING id
|
|
98
99
|
`;
|
|
100
|
+
await coreUserEventPublisher().publish(tx, "user.created", {
|
|
101
|
+
subject: rows[0]!.id,
|
|
102
|
+
data: { userId: rows[0]!.id, provider: "local" },
|
|
103
|
+
});
|
|
99
104
|
return { ok: true, data: { id: rows[0]!.id } };
|
|
100
105
|
}, params.actor);
|
|
101
106
|
} catch (error) {
|
|
@@ -52,6 +52,7 @@ export type PwaDevicePairedNotice = {
|
|
|
52
52
|
platform: PwaPlatform;
|
|
53
53
|
pairedAt: string;
|
|
54
54
|
locale: string | null;
|
|
55
|
+
pairedBy?: { userId: string | null; name: string };
|
|
55
56
|
};
|
|
56
57
|
export type PwaDeviceNotifier = (notice: PwaDevicePairedNotice) => Promise<unknown>;
|
|
57
58
|
|
|
@@ -59,6 +60,8 @@ type PairingRow = {
|
|
|
59
60
|
id: string;
|
|
60
61
|
user_id: string;
|
|
61
62
|
initiator_sid: string;
|
|
63
|
+
initiator_user_id: string | null;
|
|
64
|
+
device_confirmation_hash: string | null;
|
|
62
65
|
auth_epoch: string;
|
|
63
66
|
comparison: string | null;
|
|
64
67
|
platform: PwaPlatform | null;
|
|
@@ -83,6 +86,8 @@ type DeviceRow = {
|
|
|
83
86
|
revoked_at: Date | null;
|
|
84
87
|
locale: string | null;
|
|
85
88
|
notified_at: Date | null;
|
|
89
|
+
paired_by_user_id: string | null;
|
|
90
|
+
paired_by_name: string | null;
|
|
86
91
|
};
|
|
87
92
|
type UserRow = {
|
|
88
93
|
id: string;
|
|
@@ -126,7 +131,7 @@ export const parseDeviceKey = (value: string | null | undefined): { deviceId: st
|
|
|
126
131
|
|
|
127
132
|
/** Untranslated default names; the person renames the phone in the app. */
|
|
128
133
|
export const defaultDeviceName = (platform: PwaPlatform, userAgent: string | undefined): string =>
|
|
129
|
-
platform === "ios" ? (/\biPad\b/.test(userAgent ?? "") ? "iPad" : "iPhone") : platform === "android" ? "Android" : "
|
|
134
|
+
platform === "ios" ? (/\biPad\b/.test(userAgent ?? "") ? "iPad" : "iPhone") : platform === "android" ? "Android" : "Device";
|
|
130
135
|
|
|
131
136
|
const accountName = (user: Pick<UserRow, "display_name" | "uid">) => user.display_name?.trim() || user.uid;
|
|
132
137
|
|
|
@@ -160,10 +165,17 @@ const initiatorValid = async (tx: SQL, pairing: PairingRow): Promise<boolean> =>
|
|
|
160
165
|
SELECT 1 FROM auth.session_families sf
|
|
161
166
|
JOIN auth.users u ON u.id = sf.user_id
|
|
162
167
|
JOIN auth.signing_keys sk ON sk.kid = sf.signing_kid AND sk.state <> 'revoked'
|
|
163
|
-
WHERE sf.sid = ${pairing.initiator_sid}::uuid AND sf.user_id = ${pairing.user_id}::uuid
|
|
168
|
+
WHERE sf.sid = ${pairing.initiator_sid}::uuid AND sf.user_id = ${pairing.initiator_user_id ?? pairing.user_id}::uuid
|
|
164
169
|
AND sf.revoked_at IS NULL AND sf.expires_at > now() AND sf.auth_epoch = u.auth_epoch AND sf.pwa_device_id IS NULL
|
|
170
|
+
AND (${pairing.initiator_user_id === null} OR u.admin = true)
|
|
171
|
+
AND (${pairing.initiator_user_id === null} OR u.account_expires IS NULL OR u.account_expires > now())
|
|
165
172
|
`;
|
|
166
|
-
|
|
173
|
+
if (!row) return false;
|
|
174
|
+
if (pairing.initiator_user_id) {
|
|
175
|
+
const [admin] = await tx<UserRow[]>`SELECT * FROM auth.users WHERE id = ${pairing.initiator_user_id}::uuid`;
|
|
176
|
+
return Boolean(admin && (await isAccountCategoryAllowed(admin, tx)));
|
|
177
|
+
}
|
|
178
|
+
return true;
|
|
167
179
|
};
|
|
168
180
|
|
|
169
181
|
const accountExpired = (user: UserRow) => Boolean(user.account_expires && passed(user.account_expires));
|
|
@@ -176,6 +188,7 @@ const pairedNotice = (device: DeviceRow, name: string): PwaDevicePairedNotice =>
|
|
|
176
188
|
platform: device.platform,
|
|
177
189
|
pairedAt: iso(device.created_at),
|
|
178
190
|
locale: device.locale,
|
|
191
|
+
...(device.paired_by_name ? { pairedBy: { userId: device.paired_by_user_id, name: device.paired_by_name } } : {}),
|
|
179
192
|
});
|
|
180
193
|
|
|
181
194
|
/**
|
|
@@ -268,6 +281,14 @@ export type PwaRenewal =
|
|
|
268
281
|
|
|
269
282
|
export type PwaCompletion =
|
|
270
283
|
| { state: "paired"; credentials: PwaCredentials | null; platform: PwaPlatform }
|
|
284
|
+
| { state: "rejected" }
|
|
285
|
+
| {
|
|
286
|
+
state: "confirmation_required";
|
|
287
|
+
account: { name: string };
|
|
288
|
+
administrator: { name: string };
|
|
289
|
+
confirmationToken: string;
|
|
290
|
+
expiresAt: string;
|
|
291
|
+
}
|
|
271
292
|
| { state: "waiting"; code: string; account: { name: string }; expiresAt: string };
|
|
272
293
|
|
|
273
294
|
export const createPwaDeviceService = (hints: PwaPairingClaimHints = syncPairingClaimHints) => {
|
|
@@ -336,28 +357,48 @@ export const createPwaDeviceService = (hints: PwaPairingClaimHints = syncPairing
|
|
|
336
357
|
return value;
|
|
337
358
|
};
|
|
338
359
|
|
|
360
|
+
const startPairing = async (actor: PwaWebActor, targetUserId?: string) => {
|
|
361
|
+
const id = crypto.randomUUID();
|
|
362
|
+
const secret = pairingSecret.create();
|
|
363
|
+
const now = Date.now();
|
|
364
|
+
const claimUntil = new Date(now + limits.linkClaimSeconds * 1000);
|
|
365
|
+
const expiresAt = new Date(now + limits.pairingSeconds * 1000);
|
|
366
|
+
await sql.begin(async (tx) => {
|
|
367
|
+
const userId = targetUserId ?? actor.userId;
|
|
368
|
+
await tx`SELECT id FROM auth.users WHERE id IN (${actor.userId}::uuid, ${userId}::uuid) ORDER BY id FOR UPDATE`;
|
|
369
|
+
const account = await requireRecentWebSession(tx, actor, limits.recentSessionSeconds, (code) => fail(code, 403));
|
|
370
|
+
if (targetUserId) {
|
|
371
|
+
const [admin] = await tx`SELECT admin FROM auth.users WHERE id = ${actor.userId}::uuid`;
|
|
372
|
+
if (!admin?.admin) fail("FORBIDDEN", 403);
|
|
373
|
+
}
|
|
374
|
+
const target = targetUserId ? await lockUser(tx, targetUserId) : account;
|
|
375
|
+
if (!target) return fail("NOT_FOUND", 404);
|
|
376
|
+
if (targetUserId && (accountExpired(target) || !(await isAccountCategoryAllowed(target, tx)))) fail("ACCOUNT_BLOCKED", 403);
|
|
377
|
+
const [open] = await tx<{ count: number }[]>`
|
|
378
|
+
SELECT count(*)::int AS count FROM auth.pwa_pairings
|
|
379
|
+
WHERE user_id = ${userId}::uuid AND expires_at > now() AND state IN ('pending', 'claimed', 'awaiting_device', 'confirmed')
|
|
380
|
+
`;
|
|
381
|
+
if ((open?.count ?? 0) >= limits.pendingPerAccount) fail("LIMIT_REACHED", 429);
|
|
382
|
+
await tx`INSERT INTO auth.pwa_pairings (id, user_id, initiator_sid, initiator_user_id, auth_epoch, link_secret_hash, state, claim_until, expires_at)
|
|
383
|
+
VALUES (${id}::uuid, ${userId}::uuid, ${actor.sid}::uuid, ${targetUserId ? actor.userId : null}::uuid, ${target.auth_epoch}, ${pairingSecret.hash(secret)}, 'pending', ${claimUntil}, ${expiresAt})`;
|
|
384
|
+
await record(
|
|
385
|
+
tx,
|
|
386
|
+
"pairing.start",
|
|
387
|
+
{ type: "pwa_pairing", id },
|
|
388
|
+
actor.userId,
|
|
389
|
+
targetUserId ? { targetUserId, administratorUserId: actor.userId } : {},
|
|
390
|
+
);
|
|
391
|
+
});
|
|
392
|
+
return { id, secret, claimUntil: iso(claimUntil), expiresAt: iso(expiresAt) };
|
|
393
|
+
};
|
|
394
|
+
|
|
339
395
|
return {
|
|
340
396
|
// ---------- Web side: the initiating web session ----------
|
|
341
397
|
|
|
342
|
-
startPairing:
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
const claimUntil = new Date(now + limits.linkClaimSeconds * 1000);
|
|
347
|
-
const expiresAt = new Date(now + limits.pairingSeconds * 1000);
|
|
348
|
-
await sql.begin(async (tx) => {
|
|
349
|
-
await lockUser(tx, actor.userId);
|
|
350
|
-
const account = await requireRecentWebSession(tx, actor, limits.recentSessionSeconds, (code) => fail(code, 403));
|
|
351
|
-
const [open] = await tx<{ count: number }[]>`
|
|
352
|
-
SELECT count(*)::int AS count FROM auth.pwa_pairings
|
|
353
|
-
WHERE user_id = ${actor.userId}::uuid AND expires_at > now() AND state IN ('pending', 'claimed', 'confirmed')
|
|
354
|
-
`;
|
|
355
|
-
if ((open?.count ?? 0) >= limits.pendingPerAccount) fail("LIMIT_REACHED", 429);
|
|
356
|
-
await tx`INSERT INTO auth.pwa_pairings (id, user_id, initiator_sid, auth_epoch, link_secret_hash, state, claim_until, expires_at)
|
|
357
|
-
VALUES (${id}::uuid, ${actor.userId}::uuid, ${actor.sid}::uuid, ${account.auth_epoch}, ${pairingSecret.hash(secret)}, 'pending', ${claimUntil}, ${expiresAt})`;
|
|
358
|
-
await record(tx, "pairing.start", { type: "pwa_pairing", id }, actor.userId);
|
|
359
|
-
});
|
|
360
|
-
return { id, secret, claimUntil: iso(claimUntil), expiresAt: iso(expiresAt) };
|
|
398
|
+
startPairing: (actor: PwaWebActor) => startPairing(actor),
|
|
399
|
+
startUserPairing: (actor: PwaWebActor & PwaDeviceAdministrator, userId: string) => {
|
|
400
|
+
if (!actor.admin) return fail("FORBIDDEN", 403);
|
|
401
|
+
return startPairing(actor, userId);
|
|
361
402
|
},
|
|
362
403
|
|
|
363
404
|
/** Never returns the code: the person types what the phone shows. */
|
|
@@ -365,12 +406,12 @@ export const createPwaDeviceService = (hints: PwaPairingClaimHints = syncPairing
|
|
|
365
406
|
const read = async (validateInitiator = false) => {
|
|
366
407
|
const [p] = await sql<(PairingRow & { current_epoch: string })[]>`
|
|
367
408
|
SELECT p.*, u.auth_epoch AS current_epoch FROM auth.pwa_pairings p JOIN auth.users u ON u.id = p.user_id
|
|
368
|
-
WHERE p.id = ${id}::uuid AND p.user_id = ${actor.userId}::uuid AND p.initiator_sid = ${actor.sid}::uuid
|
|
409
|
+
WHERE p.id = ${id}::uuid AND COALESCE(p.initiator_user_id, p.user_id) = ${actor.userId}::uuid AND p.initiator_sid = ${actor.sid}::uuid
|
|
369
410
|
`;
|
|
370
411
|
if (!p) return fail("NOT_FOUND", 404);
|
|
371
412
|
if (passed(p.expires_at) || p.current_epoch !== p.auth_epoch || (p.state === "pending" && passed(p.claim_until)))
|
|
372
413
|
return fail("EXPIRED", 410);
|
|
373
|
-
if (validateInitiator && !(await initiatorValid(sql, p))) return fail("EXPIRED", 410);
|
|
414
|
+
if ((validateInitiator || p.initiator_user_id !== null) && !(await initiatorValid(sql, p))) return fail("EXPIRED", 410);
|
|
374
415
|
return {
|
|
375
416
|
state: p.state,
|
|
376
417
|
claimUntil: iso(p.claim_until),
|
|
@@ -406,10 +447,10 @@ export const createPwaDeviceService = (hints: PwaPairingClaimHints = syncPairing
|
|
|
406
447
|
confirmPairing: async (actor: PwaWebActor, id: string, code: string) => {
|
|
407
448
|
type Outcome = { ok: true } | { ok: false; code: PwaErrorCode; status: PwaError["status"]; attemptsLeft?: number };
|
|
408
449
|
const outcome = await sql.begin(async (tx): Promise<Outcome> => {
|
|
409
|
-
const
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
`;
|
|
450
|
+
const [snapshot] = await tx<PairingRow[]>`SELECT * FROM auth.pwa_pairings WHERE id = ${id}::uuid
|
|
451
|
+
AND COALESCE(initiator_user_id, user_id) = ${actor.userId}::uuid AND initiator_sid = ${actor.sid}::uuid`;
|
|
452
|
+
const user = snapshot ? await lockUser(tx, snapshot.user_id) : null;
|
|
453
|
+
const [p] = await tx<PairingRow[]>`SELECT * FROM auth.pwa_pairings WHERE id = ${snapshot?.id ?? null}::uuid FOR UPDATE`;
|
|
413
454
|
if (!user || !p) return { ok: false, code: "NOT_FOUND", status: 404 };
|
|
414
455
|
if (p.state === "cancelled" || passed(p.expires_at) || user.auth_epoch !== p.auth_epoch || !(await initiatorValid(tx, p)))
|
|
415
456
|
return { ok: false, code: "EXPIRED", status: 410 };
|
|
@@ -426,8 +467,11 @@ export const createPwaDeviceService = (hints: PwaPairingClaimHints = syncPairing
|
|
|
426
467
|
await tx`UPDATE auth.pwa_pairings SET failed_attempts = ${failed} WHERE id = ${p.id}::uuid`;
|
|
427
468
|
return { ok: false, code: "WRONG_CODE", status: 409, attemptsLeft: limits.confirmAttempts - failed };
|
|
428
469
|
}
|
|
429
|
-
await tx`UPDATE auth.pwa_pairings SET state =
|
|
430
|
-
await record(tx, "pairing.confirm", { type: "pwa_pairing", id: p.id }, actor.userId, {
|
|
470
|
+
await tx`UPDATE auth.pwa_pairings SET state = ${p.initiator_user_id ? "awaiting_device" : "confirmed"} WHERE id = ${p.id}::uuid`;
|
|
471
|
+
await record(tx, "pairing.confirm", { type: "pwa_pairing", id: p.id }, actor.userId, {
|
|
472
|
+
platform: p.platform,
|
|
473
|
+
...(p.initiator_user_id ? { targetUserId: p.user_id, administratorUserId: p.initiator_user_id } : {}),
|
|
474
|
+
});
|
|
431
475
|
return { ok: true };
|
|
432
476
|
});
|
|
433
477
|
if (!outcome.ok) fail(outcome.code, outcome.status, outcome.attemptsLeft);
|
|
@@ -435,10 +479,10 @@ export const createPwaDeviceService = (hints: PwaPairingClaimHints = syncPairing
|
|
|
435
479
|
|
|
436
480
|
cancelPairing: async (actor: PwaWebActor, id: string) => {
|
|
437
481
|
await sql.begin(async (tx) => {
|
|
438
|
-
await
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
`;
|
|
482
|
+
const [snapshot] = await tx<PairingRow[]>`SELECT * FROM auth.pwa_pairings WHERE id = ${id}::uuid
|
|
483
|
+
AND COALESCE(initiator_user_id, user_id) = ${actor.userId}::uuid AND initiator_sid = ${actor.sid}::uuid`;
|
|
484
|
+
if (snapshot) await lockUser(tx, snapshot.user_id);
|
|
485
|
+
const [p] = await tx<PairingRow[]>`SELECT * FROM auth.pwa_pairings WHERE id = ${snapshot?.id ?? null}::uuid FOR UPDATE`;
|
|
442
486
|
if (!p) return fail("NOT_FOUND", 404);
|
|
443
487
|
if (p.state === "completed") return fail("CONFLICT", 409);
|
|
444
488
|
if (p.state === "cancelled") return;
|
|
@@ -517,6 +561,28 @@ export const createPwaDeviceService = (hints: PwaPairingClaimHints = syncPairing
|
|
|
517
561
|
SELECT * FROM auth.pwa_pairings WHERE completion_secret_hash = ${pairingSecret.hash(input.completionSecret)}
|
|
518
562
|
`;
|
|
519
563
|
if (!snapshot || snapshot.state === "cancelled" || passed(snapshot.expires_at)) return fail("EXPIRED", 410);
|
|
564
|
+
if (snapshot.state === "rejected") return { state: "rejected" };
|
|
565
|
+
if (snapshot.state === "awaiting_device") {
|
|
566
|
+
return sql.begin(async (tx) => {
|
|
567
|
+
const user = await lockUser(tx, snapshot.user_id);
|
|
568
|
+
const [p] = await tx<PairingRow[]>`SELECT * FROM auth.pwa_pairings WHERE id = ${snapshot.id}::uuid FOR UPDATE`;
|
|
569
|
+
if (!user || !p || passed(p.expires_at) || user.auth_epoch !== p.auth_epoch || !(await initiatorValid(tx, p)))
|
|
570
|
+
return fail("EXPIRED", 410);
|
|
571
|
+
if (p.state !== "awaiting_device" || !p.initiator_user_id) return fail("CONFLICT", 409);
|
|
572
|
+
if (accountExpired(user) || !(await isAccountCategoryAllowed(user, tx))) return fail("ACCOUNT_BLOCKED", 403);
|
|
573
|
+
const [admin] = await tx<UserRow[]>`SELECT * FROM auth.users WHERE id = ${p.initiator_user_id}::uuid`;
|
|
574
|
+
if (!admin) return fail("EXPIRED", 410);
|
|
575
|
+
const confirmationToken = pairingSecret.create();
|
|
576
|
+
await tx`UPDATE auth.pwa_pairings SET device_confirmation_hash = ${pairingSecret.hash(confirmationToken)} WHERE id = ${p.id}::uuid`;
|
|
577
|
+
return {
|
|
578
|
+
state: "confirmation_required" as const,
|
|
579
|
+
account: { name: accountName(user) },
|
|
580
|
+
administrator: { name: accountName(admin) },
|
|
581
|
+
confirmationToken,
|
|
582
|
+
expiresAt: iso(p.expires_at),
|
|
583
|
+
};
|
|
584
|
+
});
|
|
585
|
+
}
|
|
520
586
|
if (snapshot.state === "claimed") {
|
|
521
587
|
const [user] = await sql<UserRow[]>`SELECT * FROM auth.users WHERE id = ${snapshot.user_id}::uuid`;
|
|
522
588
|
// Signing out on the web, or everywhere, voids the pairing at once.
|
|
@@ -570,12 +636,17 @@ export const createPwaDeviceService = (hints: PwaPairingClaimHints = syncPairing
|
|
|
570
636
|
// An unusually long locale tag is dropped; the notice then uses the operator's locale.
|
|
571
637
|
const locale = input.locale && input.locale.length <= 35 ? input.locale : null;
|
|
572
638
|
const [device] = await tx<DeviceRow[]>`
|
|
573
|
-
INSERT INTO auth.pwa_devices (id, user_id, name, platform, auth_epoch, secret_hash, rotated_at, locale)
|
|
574
|
-
VALUES (${deviceId}::uuid, ${user.id}::uuid, ${p.device_name}, ${p.platform}, ${user.auth_epoch}, ${pairingSecret.hash(secret)}, now(), ${locale}
|
|
639
|
+
INSERT INTO auth.pwa_devices (id, user_id, name, platform, auth_epoch, secret_hash, rotated_at, locale, paired_by_user_id, paired_by_name)
|
|
640
|
+
VALUES (${deviceId}::uuid, ${user.id}::uuid, ${p.device_name}, ${p.platform}, ${user.auth_epoch}, ${pairingSecret.hash(secret)}, now(), ${locale}, ${p.initiator_user_id}::uuid,
|
|
641
|
+
(SELECT COALESCE(NULLIF(trim(display_name), ''), uid) FROM auth.users WHERE id = ${p.initiator_user_id}::uuid))
|
|
575
642
|
RETURNING *`;
|
|
576
643
|
await issue(user.id, appSession(deviceId));
|
|
577
644
|
await tx`UPDATE auth.pwa_pairings SET state = 'completed', device_id = ${deviceId}::uuid WHERE id = ${p.id}::uuid`;
|
|
578
|
-
await record(tx, "device.enroll", { type: "pwa_device", id: deviceId },
|
|
645
|
+
await record(tx, "device.enroll", { type: "pwa_device", id: deviceId }, p.initiator_user_id ?? user.id, {
|
|
646
|
+
platform: p.platform,
|
|
647
|
+
pairingId: p.id,
|
|
648
|
+
...(p.initiator_user_id ? { targetUserId: user.id, administratorUserId: p.initiator_user_id } : {}),
|
|
649
|
+
});
|
|
579
650
|
return { kind: "issued", deviceKey: `${deviceId}.${secret}`, notice: pairedNotice(device!, p.device_name) };
|
|
580
651
|
});
|
|
581
652
|
const platform = snapshot.platform ?? "other";
|
|
@@ -585,6 +656,45 @@ export const createPwaDeviceService = (hints: PwaPairingClaimHints = syncPairing
|
|
|
585
656
|
return { state: "paired", credentials: { deviceKey: value.deviceKey, sessionToken: token }, platform };
|
|
586
657
|
},
|
|
587
658
|
|
|
659
|
+
decidePairing: async (
|
|
660
|
+
completionSecret: string | null | undefined,
|
|
661
|
+
input: { confirmationToken: string; decision: "confirm" | "reject" },
|
|
662
|
+
) => {
|
|
663
|
+
if (!completionSecret) return fail("EXPIRED", 410);
|
|
664
|
+
const [snapshot] = await sql<
|
|
665
|
+
PairingRow[]
|
|
666
|
+
>`SELECT * FROM auth.pwa_pairings WHERE completion_secret_hash = ${pairingSecret.hash(completionSecret)}`;
|
|
667
|
+
if (!snapshot) return fail("EXPIRED", 410);
|
|
668
|
+
await sql.begin(async (tx) => {
|
|
669
|
+
const user = await lockUser(tx, snapshot.user_id);
|
|
670
|
+
const [p] = await tx<PairingRow[]>`SELECT * FROM auth.pwa_pairings WHERE id = ${snapshot.id}::uuid FOR UPDATE`;
|
|
671
|
+
if (
|
|
672
|
+
!user ||
|
|
673
|
+
!p ||
|
|
674
|
+
!p.initiator_user_id ||
|
|
675
|
+
passed(p.expires_at) ||
|
|
676
|
+
user.auth_epoch !== p.auth_epoch ||
|
|
677
|
+
!(await initiatorValid(tx, p))
|
|
678
|
+
)
|
|
679
|
+
return fail("EXPIRED", 410);
|
|
680
|
+
if (!p.device_confirmation_hash || !pairingSecret.matches(input.confirmationToken, p.device_confirmation_hash))
|
|
681
|
+
return fail("FORBIDDEN", 403);
|
|
682
|
+
const next = input.decision === "confirm" ? "confirmed" : "rejected";
|
|
683
|
+
if (p.state === next || (next === "confirmed" && p.state === "completed")) return;
|
|
684
|
+
if (p.state !== "awaiting_device") return fail("CONFLICT", 409);
|
|
685
|
+
if (accountExpired(user) || !(await isAccountCategoryAllowed(user, tx))) return fail("ACCOUNT_BLOCKED", 403);
|
|
686
|
+
await tx`UPDATE auth.pwa_pairings SET state = ${next} WHERE id = ${p.id}::uuid`;
|
|
687
|
+
await record(
|
|
688
|
+
tx,
|
|
689
|
+
input.decision === "confirm" ? "pairing.device_confirm" : "pairing.reject",
|
|
690
|
+
{ type: "pwa_pairing", id: p.id },
|
|
691
|
+
user.id,
|
|
692
|
+
{ targetUserId: user.id, administratorUserId: p.initiator_user_id },
|
|
693
|
+
);
|
|
694
|
+
});
|
|
695
|
+
hints.publish(snapshot.id);
|
|
696
|
+
},
|
|
697
|
+
|
|
588
698
|
/** Renewal with device-key rotation; `launch` and `renew` share it. */
|
|
589
699
|
renew: renewal,
|
|
590
700
|
|
|
@@ -491,8 +491,10 @@ export const createStandaloneApiToken = async (params: {
|
|
|
491
491
|
name: string;
|
|
492
492
|
expiresAt?: string | null;
|
|
493
493
|
scopes?: string[];
|
|
494
|
+
access?: "full" | "models";
|
|
494
495
|
}): Promise<Result<{ credential: ServiceAccountCredential; token: string }>> => {
|
|
495
|
-
if (params.scopes?.includes(AI_INFERENCE_SCOPE)
|
|
496
|
+
if (params.scopes?.includes(AI_INFERENCE_SCOPE) || (params.access === "models" && params.scopes?.length))
|
|
497
|
+
return fail(err.badInput("Models-only access cannot be combined with general credential scopes"));
|
|
496
498
|
const serviceAccount = await serviceAccounts.get({ id: params.serviceAccountId });
|
|
497
499
|
if (!serviceAccount || !isStandaloneServiceAccountKind(serviceAccount.kind)) return fail(err.notFound("Service account"));
|
|
498
500
|
if (serviceAccount.status !== "active") return fail(err.badInput("Service account is disabled"));
|
|
@@ -503,7 +505,7 @@ export const createStandaloneApiToken = async (params: {
|
|
|
503
505
|
name: params.name,
|
|
504
506
|
expiresAt: params.expiresAt,
|
|
505
507
|
createdBy: params.actor.id,
|
|
506
|
-
scopes: params.scopes,
|
|
508
|
+
scopes: params.access === "models" ? [AI_INFERENCE_SCOPE] : params.scopes,
|
|
507
509
|
});
|
|
508
510
|
|
|
509
511
|
return audit.recordResult({
|
|
@@ -514,6 +516,7 @@ export const createStandaloneApiToken = async (params: {
|
|
|
514
516
|
serviceAccountId: serviceAccount.id,
|
|
515
517
|
kind: "api_token",
|
|
516
518
|
serviceAccountKind: serviceAccount.kind,
|
|
519
|
+
access: params.access ?? "full",
|
|
517
520
|
expiresAt: params.expiresAt ?? null,
|
|
518
521
|
},
|
|
519
522
|
result,
|
|
@@ -31,6 +31,14 @@ import { loadJwtSession, loadJwtSessionIdentity } from "./user";
|
|
|
31
31
|
|
|
32
32
|
const log = logger("auth:session");
|
|
33
33
|
|
|
34
|
+
/** A browser already signed in must prove that same account before continuing. */
|
|
35
|
+
export class SessionAccountMismatchError extends Error {
|
|
36
|
+
constructor() {
|
|
37
|
+
super("Sign in with the account already open in this browser.");
|
|
38
|
+
this.name = "SessionAccountMismatchError";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
34
42
|
export type SessionKind = "web" | "app";
|
|
35
43
|
|
|
36
44
|
export type SessionData = {
|
|
@@ -585,6 +593,12 @@ export const session = {
|
|
|
585
593
|
appDeviceId?: string;
|
|
586
594
|
},
|
|
587
595
|
): Promise<string> => {
|
|
596
|
+
if (c.req.header("X-Cloud-Reauthenticate") === "1") {
|
|
597
|
+
const currentToken = session.getWebToken(c);
|
|
598
|
+
const current = currentToken ? await session.authenticateRequest(c, currentToken) : null;
|
|
599
|
+
if (!current || current.user.id !== userId) throw new SessionAccountMismatchError();
|
|
600
|
+
}
|
|
601
|
+
|
|
588
602
|
const expiryHours = await settings.get<number>("user.session.expiry_hours");
|
|
589
603
|
if (!Number.isFinite(expiryHours) || expiryHours < 1) throw new Error("user.session.expiry_hours must be a positive number");
|
|
590
604
|
const ttlSeconds = Math.floor(expiryHours * 60 * 60);
|
|
@@ -30,6 +30,76 @@ const IPA_ACCOUNT_TRANSITION_OPTIONS = [
|
|
|
30
30
|
] as const;
|
|
31
31
|
|
|
32
32
|
export const CORE_SETTINGS = {
|
|
33
|
+
"storage.endpoint": {
|
|
34
|
+
kind: "url",
|
|
35
|
+
label: "Garage S3 endpoint",
|
|
36
|
+
description: "Garage S3 API origin, such as https://s3.example.org.",
|
|
37
|
+
default: "",
|
|
38
|
+
presentation: {
|
|
39
|
+
translations: { de: { label: "Garage-S3-Endpunkt", description: "Origin der Garage-S3-API, etwa https://s3.example.org." } },
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
"storage.region": {
|
|
43
|
+
kind: "string",
|
|
44
|
+
label: "Signing region",
|
|
45
|
+
description: "Must match the Garage S3 region.",
|
|
46
|
+
default: "garage",
|
|
47
|
+
presentation: { translations: { de: { label: "Signaturregion", description: "Muss der S3-Region von Garage entsprechen." } } },
|
|
48
|
+
},
|
|
49
|
+
"storage.bucket": {
|
|
50
|
+
kind: "string",
|
|
51
|
+
label: "Bucket",
|
|
52
|
+
description: "One private Garage bucket for application objects; keep website mode disabled.",
|
|
53
|
+
default: "",
|
|
54
|
+
presentation: {
|
|
55
|
+
translations: {
|
|
56
|
+
de: { label: "Bucket", description: "Ein privater Garage-Bucket für App-Dateien; Website-Modus ausgeschaltet lassen." },
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
"storage.access_key_id": {
|
|
61
|
+
kind: "secret",
|
|
62
|
+
label: "Access key ID",
|
|
63
|
+
description: "Installation-wide key with read and write access to the configured bucket.",
|
|
64
|
+
default: "",
|
|
65
|
+
presentation: {
|
|
66
|
+
translations: {
|
|
67
|
+
de: {
|
|
68
|
+
label: "Access-Key-ID",
|
|
69
|
+
description: "Installationsweiter Schlüssel mit Lese- und Schreibzugriff auf den eingetragenen Bucket.",
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
"storage.secret_access_key": {
|
|
75
|
+
kind: "secret",
|
|
76
|
+
label: "Secret access key",
|
|
77
|
+
description: "Secret belonging to the installation-wide Garage key.",
|
|
78
|
+
default: "",
|
|
79
|
+
presentation: {
|
|
80
|
+
translations: {
|
|
81
|
+
de: { label: "Geheimer Zugriffsschlüssel", description: "Geheimer Teil des installationsweiten Garage-Schlüssels." },
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
"storage.max_object_bytes": {
|
|
86
|
+
kind: "number",
|
|
87
|
+
label: "Maximum object size (bytes)",
|
|
88
|
+
description: "Default 32 MiB covers 25 MB documents and 1–5 MB images. Budget memory for concurrent operations.",
|
|
89
|
+
default: 32 * 1024 ** 2,
|
|
90
|
+
min: 1,
|
|
91
|
+
max: 5 * 1024 ** 3,
|
|
92
|
+
integer: true,
|
|
93
|
+
presentation: {
|
|
94
|
+
translations: {
|
|
95
|
+
de: {
|
|
96
|
+
label: "Maximale Objektgröße (Bytes)",
|
|
97
|
+
description: "32 MiB reichen für Dokumente bis 25 MB und Bilder von 1–5 MB. Speicher für gleichzeitige Vorgänge einplanen.",
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
|
|
33
103
|
"outgoing_mail.content_retention_days": {
|
|
34
104
|
kind: "number",
|
|
35
105
|
label: "Mail content retention (days)",
|
|
@@ -69,6 +139,22 @@ export const CORE_SETTINGS = {
|
|
|
69
139
|
"Trusted HTTPS origin of the authenticator, used for app installation and pairing links. Changing this origin requires reviewing existing devices; never use a wildcard.",
|
|
70
140
|
default: "https://cloud-login.pwa.k2b.dev",
|
|
71
141
|
},
|
|
142
|
+
"user.app_approval.label": {
|
|
143
|
+
kind: "string",
|
|
144
|
+
label: "Name in Cloud Login",
|
|
145
|
+
description:
|
|
146
|
+
"Suggested label for new Cloud Login connections. Empty uses the application display name. People can change it; existing connections keep their names.",
|
|
147
|
+
default: "",
|
|
148
|
+
presentation: {
|
|
149
|
+
translations: {
|
|
150
|
+
de: {
|
|
151
|
+
label: "Name in Cloud Login",
|
|
152
|
+
description:
|
|
153
|
+
"Namensvorschlag für neue Cloud-Login-Verbindungen. Leer übernimmt den Anzeigenamen der Anwendung. Bestehende Verbindungen behalten ihren Namen; Nutzer können den Vorschlag ändern.",
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
},
|
|
72
158
|
"user.app_approval.admin_pairing": {
|
|
73
159
|
kind: "boolean",
|
|
74
160
|
label: "Allow administrator-assisted pairing",
|
|
@@ -299,48 +385,6 @@ export const CORE_SETTINGS = {
|
|
|
299
385
|
default: false,
|
|
300
386
|
description: "Enable Cloud AI features including personal conversations, Assistant, and app Capabilities.",
|
|
301
387
|
},
|
|
302
|
-
"ai.default_model_id": {
|
|
303
|
-
kind: "string",
|
|
304
|
-
label: "Default Model ID",
|
|
305
|
-
default: "",
|
|
306
|
-
description: "Model profile id used when an app asks for the platform default AI model.",
|
|
307
|
-
placeholder: "e.g. openrouter-fast",
|
|
308
|
-
},
|
|
309
|
-
"ai.model_profiles_json": {
|
|
310
|
-
kind: "text",
|
|
311
|
-
label: "Model Profiles",
|
|
312
|
-
default: "[]",
|
|
313
|
-
description: "JSON array of model profiles. Provider API keys are stored on the profile they belong to.",
|
|
314
|
-
placeholder:
|
|
315
|
-
'[{"id":"openrouter-fast","label":"OpenRouter Fast","provider":"openrouter","model":"openai/gpt-4.1-mini","enabled":true,"dataBoundary":"hosted","capabilities":["streaming"],"apiKey":"sk-or-..."}]',
|
|
316
|
-
},
|
|
317
|
-
"ai.background_model_id": {
|
|
318
|
-
kind: "string",
|
|
319
|
-
label: "Background Model ID",
|
|
320
|
-
default: "",
|
|
321
|
-
description: "Model profile id used for background AI jobs. Empty = platform default model.",
|
|
322
|
-
placeholder: "e.g. openrouter-fast",
|
|
323
|
-
},
|
|
324
|
-
"ai.audio_model_id": {
|
|
325
|
-
kind: "string",
|
|
326
|
-
label: "Audio Model ID",
|
|
327
|
-
default: "",
|
|
328
|
-
description: "Audio transcription model profile for dictation and audio files. Empty disables transcription.",
|
|
329
|
-
},
|
|
330
|
-
"ai.vision_model_id": {
|
|
331
|
-
kind: "string",
|
|
332
|
-
label: "Vision Model ID",
|
|
333
|
-
default: "",
|
|
334
|
-
description: "Vision-capable model profile used by the view_image tool. Empty disables the tool fallback.",
|
|
335
|
-
placeholder: "e.g. openrouter-vision",
|
|
336
|
-
},
|
|
337
|
-
"ai.workflow_model_id": {
|
|
338
|
-
kind: "string",
|
|
339
|
-
label: "Workflow Model ID",
|
|
340
|
-
default: "",
|
|
341
|
-
description: "Model profile id used for AI workflow actions. Empty = background model, then platform default.",
|
|
342
|
-
placeholder: "e.g. openrouter-fast",
|
|
343
|
-
},
|
|
344
388
|
"ai.enrich_cron": {
|
|
345
389
|
kind: "cron",
|
|
346
390
|
label: "Chat Enrichment Schedule",
|
|
@@ -428,20 +472,20 @@ export const CORE_SETTINGS = {
|
|
|
428
472
|
label: "OpenAI-compatible API",
|
|
429
473
|
default: false,
|
|
430
474
|
description:
|
|
431
|
-
"Let people create API keys
|
|
475
|
+
"Let people create personal models-only API keys and administrators issue them to standalone accounts and agents. Keys use only models granted to their account. 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.",
|
|
432
476
|
},
|
|
433
477
|
// Default 4: one process runs at most 8 chat turns at once (AI_TURN_WORKER_CONCURRENCY in ai/runtime.ts), and this
|
|
434
|
-
// synchronous API bypasses that queue, so one
|
|
478
|
+
// synchronous API bypasses that queue, so one account may hold at most half of it. Typical clients need 1-2 (one
|
|
435
479
|
// request after another plus a connection test); 4 leaves room for a second client without letting a script in a
|
|
436
480
|
// loop crowd out everyone else. Operators with more provider capacity raise it.
|
|
437
481
|
"ai.openai_api_max_concurrent_requests": {
|
|
438
482
|
kind: "number",
|
|
439
|
-
label: "OpenAI API concurrent requests per
|
|
483
|
+
label: "OpenAI API concurrent requests per account",
|
|
440
484
|
integer: true,
|
|
441
485
|
min: 1,
|
|
442
486
|
max: 64,
|
|
443
487
|
default: 4,
|
|
444
|
-
description: "Maximum simultaneous OpenAI-compatible API requests per
|
|
488
|
+
description: "Maximum simultaneous OpenAI-compatible API requests per account across all Cloud instances.",
|
|
445
489
|
},
|
|
446
490
|
// ── PDF rendering ─────────────────────────────────────────────────────
|
|
447
491
|
"gotenberg.url": {
|
|
@@ -812,7 +856,7 @@ export const CORE_SETTINGS = {
|
|
|
812
856
|
label: "Requests Per Second",
|
|
813
857
|
default: 60,
|
|
814
858
|
min: 1,
|
|
815
|
-
description: "Maximum API requests per second per IP address",
|
|
859
|
+
description: "Maximum API and public storage requests per second per IP address, with separate budgets",
|
|
816
860
|
},
|
|
817
861
|
|
|
818
862
|
// ── Command line ────────────────────────────────────────────────────────
|
|
@@ -209,6 +209,9 @@ export { invalidateSettingsCache, readStates, SettingUnreadableError } from "./s
|
|
|
209
209
|
export async function getAll(locale?: string): Promise<SettingEntry[]> {
|
|
210
210
|
const allKeys = SETTINGS.map((def) => def.key);
|
|
211
211
|
const values = await readStates(allKeys);
|
|
212
|
+
const versions = await sql<
|
|
213
|
+
{ key: string; updated_at: Date; updated_by: string | null; name: string | null }[]
|
|
214
|
+
>`SELECT e.key,e.updated_at,e.updated_by,COALESCE(NULLIF(u.display_name,''),u.uid) AS name FROM settings.entries e LEFT JOIN auth.users u ON u.id=e.updated_by`;
|
|
212
215
|
|
|
213
216
|
return SETTINGS.map((def) => {
|
|
214
217
|
const presentation = resolveSettingPresentation(def, locale);
|
|
@@ -218,6 +221,11 @@ export async function getAll(locale?: string): Promise<SettingEntry[]> {
|
|
|
218
221
|
const resetValue = envFallback ?? def.default;
|
|
219
222
|
|
|
220
223
|
return {
|
|
224
|
+
updatedAt: versions.find((row) => row.key === def.key)?.updated_at.toISOString() ?? null,
|
|
225
|
+
updatedBy: (() => {
|
|
226
|
+
const row = versions.find((row) => row.key === def.key);
|
|
227
|
+
return row?.updated_by ? { id: row.updated_by, name: row.name ?? row.updated_by } : null;
|
|
228
|
+
})(),
|
|
221
229
|
key: def.key,
|
|
222
230
|
label: presentation.label,
|
|
223
231
|
kind: def.kind,
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { sql } from "bun";
|
|
2
|
+
import { ConfigConflict } from "../config-conflict";
|
|
3
|
+
import { SETTINGS_MAP } from "./defaults";
|
|
4
|
+
import { readKeyState } from "./store";
|
|
5
|
+
|
|
6
|
+
/** Lock missing keys as well as existing rows, so first writes have the same conflict guarantee. */
|
|
7
|
+
export const assertSettingVersions = async (expected: Record<string, string | null>, db = sql): Promise<void> => {
|
|
8
|
+
for (const key of Object.keys(expected).sort()) {
|
|
9
|
+
await db`SELECT pg_advisory_xact_lock(hashtext(${`settings:${key}`}))`;
|
|
10
|
+
const [row] = await db<
|
|
11
|
+
{ updated_at: Date; updated_by: string | null }[]
|
|
12
|
+
>`SELECT updated_at,updated_by FROM settings.entries WHERE key=${key} FOR UPDATE`;
|
|
13
|
+
if ((row?.updated_at.toISOString() ?? null) === expected[key]) continue;
|
|
14
|
+
const [who] = row?.updated_by
|
|
15
|
+
? await db<
|
|
16
|
+
{ id: string; name: string }[]
|
|
17
|
+
>`SELECT id,COALESCE(NULLIF(display_name,''),uid) AS name FROM auth.users WHERE id=${row.updated_by}::uuid`
|
|
18
|
+
: [];
|
|
19
|
+
throw new ConfigConflict(
|
|
20
|
+
SETTINGS_MAP.has(key) && SETTINGS_MAP.get(key)?.kind !== "secret" ? (await readKeyState(key, db)).value : "",
|
|
21
|
+
who ?? null,
|
|
22
|
+
row?.updated_at.toISOString() ?? new Date(0).toISOString(),
|
|
23
|
+
key,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
};
|