@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
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { objectStoreBackend } from "../_internal/object-store-config";
|
|
2
|
+
import { validateObjectKey } from "../_internal/object-store-key";
|
|
3
|
+
import { get } from "../services/settings";
|
|
4
|
+
import { clientIpBucket } from "./client-ip";
|
|
5
|
+
import { ratelimit } from "./ratelimit";
|
|
6
|
+
|
|
7
|
+
const notFound = () => new Response(null, { status: 404, headers: { "Cache-Control": "no-store" } });
|
|
8
|
+
|
|
9
|
+
/** Platform-owned anonymous delivery. Never accepts a bucket, private prefix or listing. */
|
|
10
|
+
export const servePublicStorage = async (request: Request, clientAddress = "unknown"): Promise<Response> => {
|
|
11
|
+
let path: string;
|
|
12
|
+
try {
|
|
13
|
+
// Validate before application decoding; Bun has already resolved transport
|
|
14
|
+
// dot segments. The S3 key below always starts in the public area.
|
|
15
|
+
const raw = request.url.replace(/^[a-z]+:\/\/[^/]+/i, "").split(/[?#]/, 1)[0] ?? "";
|
|
16
|
+
if (/[\\]|%(?:2e|2f|5c)/i.test(raw)) return notFound();
|
|
17
|
+
const match = /^\/public\/storage\/([a-z][a-z0-9-]{0,79})\/(.+)$/.exec(raw);
|
|
18
|
+
if (!match || match[1] === "storage") return notFound();
|
|
19
|
+
const area = `public/${match[1]}/`;
|
|
20
|
+
path = area + validateObjectKey(decodeURIComponent(match[2] ?? ""), area);
|
|
21
|
+
} catch {
|
|
22
|
+
return notFound();
|
|
23
|
+
}
|
|
24
|
+
if (request.method !== "GET" && request.method !== "HEAD") return notFound();
|
|
25
|
+
try {
|
|
26
|
+
const perSecond = Math.max(1, Math.floor(await get<number>("security.rate_limit_per_second")));
|
|
27
|
+
const budget = await ratelimit({ id: "public-storage", limit: perSecond }).check(clientIpBucket(clientAddress));
|
|
28
|
+
if (budget.limited)
|
|
29
|
+
return new Response("Rate limit exceeded.", {
|
|
30
|
+
status: 429,
|
|
31
|
+
headers: { "Cache-Control": "no-store", "Retry-After": String(Math.max(1, Math.ceil(budget.resetIn / 1000))) },
|
|
32
|
+
});
|
|
33
|
+
const { client, limit } = await objectStoreBackend();
|
|
34
|
+
const stat = await client.stat(path);
|
|
35
|
+
if (stat.size > limit) return notFound();
|
|
36
|
+
const headers = new Headers({
|
|
37
|
+
"Content-Type": stat.type || "application/octet-stream",
|
|
38
|
+
"Content-Length": String(stat.size),
|
|
39
|
+
ETag: stat.etag,
|
|
40
|
+
"Last-Modified": stat.lastModified.toUTCString(),
|
|
41
|
+
"Cache-Control": "public, max-age=300",
|
|
42
|
+
"X-Content-Type-Options": "nosniff",
|
|
43
|
+
// Apply to every type, including mislabelled or future active formats.
|
|
44
|
+
"Content-Security-Policy": "sandbox; default-src 'none'; frame-ancestors 'none'",
|
|
45
|
+
});
|
|
46
|
+
const essence = stat.type.split(";", 1)[0]?.trim().toLowerCase() ?? "";
|
|
47
|
+
const inline =
|
|
48
|
+
/^(?:image\/(?:png|jpeg|gif|webp|avif|bmp|x-icon)|audio\/(?:mpeg|ogg|wav|webm|mp4)|video\/(?:mp4|webm|ogg)|text\/plain)$/;
|
|
49
|
+
if (!inline.test(essence)) headers.set("Content-Disposition", "attachment");
|
|
50
|
+
const ifNoneMatch = request.headers.get("if-none-match");
|
|
51
|
+
const modifiedSince = request.headers.get("if-modified-since");
|
|
52
|
+
if (
|
|
53
|
+
ifNoneMatch
|
|
54
|
+
? ifNoneMatch.split(",").some((value) => value.trim() === stat.etag || value.trim() === "*")
|
|
55
|
+
: modifiedSince && stat.lastModified.getTime() <= Date.parse(modifiedSince)
|
|
56
|
+
) {
|
|
57
|
+
headers.delete("Content-Length");
|
|
58
|
+
return new Response(null, { status: 304, headers });
|
|
59
|
+
}
|
|
60
|
+
if (request.method === "HEAD") return new Response(null, { headers });
|
|
61
|
+
const reader = client.file(path).stream().getReader();
|
|
62
|
+
let size = 0;
|
|
63
|
+
const abort = () => {
|
|
64
|
+
void reader.cancel(request.signal.reason).catch(() => {});
|
|
65
|
+
};
|
|
66
|
+
request.signal.addEventListener("abort", abort, { once: true });
|
|
67
|
+
const release = () => {
|
|
68
|
+
request.signal.removeEventListener("abort", abort);
|
|
69
|
+
reader.releaseLock();
|
|
70
|
+
};
|
|
71
|
+
if (request.signal.aborted) abort();
|
|
72
|
+
const stream = new ReadableStream<Uint8Array>(
|
|
73
|
+
{
|
|
74
|
+
async pull(controller) {
|
|
75
|
+
try {
|
|
76
|
+
const next = await reader.read();
|
|
77
|
+
if (next.done) {
|
|
78
|
+
release();
|
|
79
|
+
controller.close();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
size += next.value.byteLength;
|
|
83
|
+
if (size > limit) throw new RangeError("Public object exceeds the configured size limit.");
|
|
84
|
+
controller.enqueue(next.value);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
await reader.cancel(error).catch(() => {});
|
|
87
|
+
release();
|
|
88
|
+
controller.error(error);
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
async cancel(reason) {
|
|
92
|
+
try {
|
|
93
|
+
await reader.cancel(reason);
|
|
94
|
+
} finally {
|
|
95
|
+
release();
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
{ highWaterMark: 0 },
|
|
100
|
+
);
|
|
101
|
+
return new Response(stream, { headers });
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (error instanceof Error && "code" in error && ["NoSuchKey", "NotFound", "ENOENT"].includes(String(error.code))) return notFound();
|
|
104
|
+
return new Response("Storage is unavailable.", { status: 503, headers: { "Cache-Control": "no-store" } });
|
|
105
|
+
}
|
|
106
|
+
};
|
|
@@ -5,6 +5,7 @@ import { accessMessages } from "../../access/messages";
|
|
|
5
5
|
import { recursiveGroupIdsSubquery } from "../../services/accounts/group-sql";
|
|
6
6
|
import { toPgUuidArray } from "../../services/postgres";
|
|
7
7
|
import type { ServiceAccountKind } from "../../services/service-accounts";
|
|
8
|
+
import type { RequestAuthority } from "../middleware/auth";
|
|
8
9
|
|
|
9
10
|
export type AccessDb = typeof sql;
|
|
10
11
|
|
|
@@ -305,6 +306,13 @@ export const deleteAccess = async (params: { id: string }, db: AccessDb = sql):
|
|
|
305
306
|
* Each app implements this to connect their resources to auth.access.
|
|
306
307
|
*/
|
|
307
308
|
export type ResourceAccessAdapter<TResourceId = string> = {
|
|
309
|
+
/**
|
|
310
|
+
* Whether any resource in this adapter has an effective read/write/admin right.
|
|
311
|
+
* Use an existence query, never enumerate resources. Preserve domain eligibility,
|
|
312
|
+
* principal semantics, resource binding and credential ceilings. Use db and signal.
|
|
313
|
+
* Optional for existing adapters; required when selected by appAccess.resources().
|
|
314
|
+
*/
|
|
315
|
+
hasAnyRight?: (subject: AccessSubject, context: { authority: RequestAuthority; db: AccessDb; signal: AbortSignal }) => Promise<boolean>;
|
|
308
316
|
/** Get all access entries for a resource */
|
|
309
317
|
list: (resourceId: TResourceId) => Promise<AccessEntry[]>;
|
|
310
318
|
/** Add an access entry to a resource */
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { sql } from "bun";
|
|
2
2
|
import { accountCategory } from "../../contracts/account-categories";
|
|
3
|
+
import type { PersonAvatar } from "../../contracts/avatar";
|
|
3
4
|
import type {
|
|
4
5
|
BaseGroup,
|
|
5
6
|
BaseUser,
|
|
@@ -7,6 +8,8 @@ import type {
|
|
|
7
8
|
EntityListItem,
|
|
8
9
|
GroupMember,
|
|
9
10
|
MutationResult,
|
|
11
|
+
ServiceAccountKind,
|
|
12
|
+
ServiceAccountStatus,
|
|
10
13
|
User,
|
|
11
14
|
UserProfile,
|
|
12
15
|
UserProvider,
|
|
@@ -34,6 +37,7 @@ import { providers } from "../providers";
|
|
|
34
37
|
import { session } from "../session";
|
|
35
38
|
import { get as getSetting } from "../settings";
|
|
36
39
|
import { type AccountsActor, canMutateManagedGroup, hasOnlySelfUpdateFields, isAdminActor, isSelfTarget } from "./authz";
|
|
40
|
+
import type { AvatarChange, AvatarState } from "./avatar";
|
|
37
41
|
import { listDuplicateEmails } from "./duplicate-emails";
|
|
38
42
|
import * as entities from "./entities";
|
|
39
43
|
import * as groups from "./groups";
|
|
@@ -378,6 +382,29 @@ export const accountsAppService = {
|
|
|
378
382
|
result,
|
|
379
383
|
});
|
|
380
384
|
},
|
|
385
|
+
getAvatarState: async (config: { id: string }) => users.getAvatarState(config),
|
|
386
|
+
/** One save of the profile picture dialog: a photo, a built avatar, or which stored picture others see. */
|
|
387
|
+
updateAvatar: async (config: { actor: AccountsActor; id: string; change: AvatarChange }) => {
|
|
388
|
+
const target = await users.getMinimal({ id: config.id });
|
|
389
|
+
const targetInfo = userTarget(target);
|
|
390
|
+
const selfService = config.actor.userId === config.id;
|
|
391
|
+
if (!selfService) {
|
|
392
|
+
const adminError = await requireAdminActor<AvatarState<PersonAvatar>>({
|
|
393
|
+
actor: config.actor,
|
|
394
|
+
action: "accounts.user.avatar.update",
|
|
395
|
+
target: targetInfo,
|
|
396
|
+
});
|
|
397
|
+
if (adminError) return adminError;
|
|
398
|
+
}
|
|
399
|
+
const result = fromMutationResult(await users.updateAvatar({ id: config.id, change: config.change }));
|
|
400
|
+
return recordCompletedMutation({
|
|
401
|
+
action: "accounts.user.avatar.update",
|
|
402
|
+
actor: auditActor(config.actor),
|
|
403
|
+
target: targetInfo,
|
|
404
|
+
metadata: { selfService, kind: result.ok ? result.data.kind : null, avatarHash: result.ok ? result.data.avatarHash : null },
|
|
405
|
+
result,
|
|
406
|
+
});
|
|
407
|
+
},
|
|
381
408
|
group: {
|
|
382
409
|
list: async (config: {
|
|
383
410
|
userId: string;
|
|
@@ -388,6 +415,7 @@ export const accountsAppService = {
|
|
|
388
415
|
const userGroups = await users.getGroups({
|
|
389
416
|
id: config.userId,
|
|
390
417
|
recursive: config.recursive,
|
|
418
|
+
includePersonal: false,
|
|
391
419
|
});
|
|
392
420
|
const query = config.filter?.query?.trim().toLowerCase();
|
|
393
421
|
const filtered = query ? userGroups.filter((groupName) => groupName.toLowerCase().includes(query)) : userGroups;
|
|
@@ -1227,6 +1255,8 @@ export const accountsAppService = {
|
|
|
1227
1255
|
profile?: UserProfile;
|
|
1228
1256
|
userIds?: string[];
|
|
1229
1257
|
groupIds?: string[];
|
|
1258
|
+
serviceAccountKinds?: ServiceAccountKind[];
|
|
1259
|
+
serviceAccountStatus?: ServiceAccountStatus;
|
|
1230
1260
|
excludeUserIds?: string[];
|
|
1231
1261
|
excludeGroupIds?: string[];
|
|
1232
1262
|
excludeServiceAccountIds?: string[];
|
|
@@ -1258,6 +1288,8 @@ export const accountsAppService = {
|
|
|
1258
1288
|
profile: config.profile,
|
|
1259
1289
|
userIds: config.userIds,
|
|
1260
1290
|
groupIds: config.groupIds,
|
|
1291
|
+
serviceAccountKinds: config.serviceAccountKinds,
|
|
1292
|
+
serviceAccountStatus: config.serviceAccountStatus,
|
|
1261
1293
|
excludeUserIds: config.excludeUserIds,
|
|
1262
1294
|
excludeGroupIds: config.excludeGroupIds,
|
|
1263
1295
|
excludeServiceAccountIds: config.excludeServiceAccountIds,
|
|
@@ -1629,9 +1661,9 @@ export const accountsAppService = {
|
|
|
1629
1661
|
(SELECT COUNT(*)::int FROM auth.users WHERE provider = 'local') AS local_accounts_total,
|
|
1630
1662
|
(SELECT COUNT(*)::int FROM auth.users WHERE provider = 'local' AND profile = 'user') AS local_user_accounts_total,
|
|
1631
1663
|
(SELECT COUNT(*)::int FROM auth.users WHERE provider = 'local' AND profile = 'guest') AS local_guest_accounts_total,
|
|
1632
|
-
(SELECT COUNT(*)::int FROM auth.groups) AS groups_total,
|
|
1633
|
-
(SELECT COUNT(*)::int FROM auth.groups WHERE provider = 'ipa') AS ipa_groups_total,
|
|
1634
|
-
(SELECT COUNT(*)::int FROM auth.groups WHERE provider = 'local') AS local_groups_total,
|
|
1664
|
+
(SELECT COUNT(*)::int FROM auth.groups g WHERE NOT EXISTS (SELECT 1 FROM auth.user_posix p WHERE p.primary_group_id = g.id)) AS groups_total,
|
|
1665
|
+
(SELECT COUNT(*)::int FROM auth.groups g WHERE provider = 'ipa' AND NOT EXISTS (SELECT 1 FROM auth.user_posix p WHERE p.primary_group_id = g.id)) AS ipa_groups_total,
|
|
1666
|
+
(SELECT COUNT(*)::int FROM auth.groups g WHERE provider = 'local' AND NOT EXISTS (SELECT 1 FROM auth.user_posix p WHERE p.primary_group_id = g.id)) AS local_groups_total,
|
|
1635
1667
|
(SELECT COUNT(*)::int FROM auth.account_requests WHERE status = 'pending') AS open_requests,
|
|
1636
1668
|
(
|
|
1637
1669
|
SELECT COUNT(*)::int
|
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { sql } from "bun";
|
|
3
|
+
import {
|
|
4
|
+
type AvatarConfig,
|
|
5
|
+
type AvatarKind,
|
|
6
|
+
canonicalAvatarConfig,
|
|
7
|
+
type PersonAvatar,
|
|
8
|
+
PersonAvatarSchema,
|
|
9
|
+
type RobotAvatar,
|
|
10
|
+
RobotAvatarSchema,
|
|
11
|
+
robotAvatarFromSeed,
|
|
12
|
+
} from "../../contracts/avatar";
|
|
3
13
|
import { MAX_AVATAR_BYTES, MAX_AVATAR_DATA_URL_LENGTH } from "../../contracts/profile";
|
|
4
14
|
import type { MutationResult } from "../../contracts/shared";
|
|
15
|
+
import { AVATAR_RENDERER_REVISION, renderAvatarSvg } from "../../shared/avatar-art";
|
|
5
16
|
import { stripImageMetadata } from "../image-metadata";
|
|
6
17
|
|
|
7
18
|
export { MAX_AVATAR_BYTES, MAX_AVATAR_DATA_URL_LENGTH } from "../../contracts/profile";
|
|
@@ -78,46 +89,342 @@ export const parseAvatarDataUrl = (input: string): MutationResult<ParsedAvatarDa
|
|
|
78
89
|
};
|
|
79
90
|
};
|
|
80
91
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
92
|
+
/** The image others see under an account's avatar URL. */
|
|
93
|
+
export type ServedAvatar = {
|
|
94
|
+
hash: string;
|
|
95
|
+
contentType: ParsedAvatarDataUrl["contentType"] | "image/svg+xml";
|
|
96
|
+
bytes: Buffer;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* What the profile picture dialog shows: the active kind, the built config, and the stored photo. Only the account
|
|
101
|
+
* holder (or an administrator for a service account) reads this; others see only the active picture.
|
|
102
|
+
*/
|
|
103
|
+
export type AvatarState<Built extends AvatarConfig = AvatarConfig> = {
|
|
104
|
+
kind: AvatarKind;
|
|
105
|
+
built: Built | null;
|
|
106
|
+
hasPhoto: boolean;
|
|
107
|
+
photo: string | null;
|
|
108
|
+
avatarHash: string | null;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** One save in the dialog: store and show a photo or a built avatar, or switch to what is stored. */
|
|
112
|
+
export type AvatarChange<Built extends AvatarConfig = AvatarConfig> = { dataUrl: string } | { built: Built } | { use: AvatarKind };
|
|
113
|
+
|
|
114
|
+
type AvatarRow = {
|
|
115
|
+
id: string;
|
|
116
|
+
avatar_kind: AvatarKind | null;
|
|
117
|
+
avatar_config: unknown;
|
|
118
|
+
avatar_data_url: string | null;
|
|
119
|
+
avatar_hash: string | null;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/** The hash of a built avatar; it changes with the renderer revision, so caches refetch a redrawn avatar. */
|
|
123
|
+
export const builtAvatarHash = (config: AvatarConfig): string =>
|
|
124
|
+
createHash("sha256")
|
|
125
|
+
.update(`built:${AVATAR_RENDERER_REVISION}:${canonicalAvatarConfig(config)}`)
|
|
126
|
+
.digest("hex");
|
|
88
127
|
|
|
128
|
+
const svgAvatar = (config: AvatarConfig, hash: string): ServedAvatar => ({
|
|
129
|
+
hash,
|
|
130
|
+
contentType: "image/svg+xml",
|
|
131
|
+
bytes: Buffer.from(renderAvatarSvg(config)),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const photoAvatar = (row: AvatarRow): ServedAvatar | null => {
|
|
135
|
+
if (!row.avatar_data_url || !row.avatar_hash) return null;
|
|
89
136
|
const parsed = parseAvatarDataUrl(row.avatar_data_url);
|
|
90
|
-
if (!parsed.ok) return null;
|
|
91
|
-
if (parsed.data.hash !== row.avatar_hash) return null;
|
|
137
|
+
if (!parsed.ok || parsed.data.hash !== row.avatar_hash) return null;
|
|
92
138
|
return parsed.data;
|
|
93
139
|
};
|
|
94
140
|
|
|
95
|
-
|
|
96
|
-
const parsed =
|
|
97
|
-
|
|
141
|
+
const parsePerson = (value: unknown): PersonAvatar | null => {
|
|
142
|
+
const parsed = PersonAvatarSchema.safeParse(value);
|
|
143
|
+
return parsed.success ? parsed.data : null;
|
|
144
|
+
};
|
|
145
|
+
const parseRobot = (value: unknown): RobotAvatar | null => {
|
|
146
|
+
const parsed = RobotAvatarSchema.safeParse(value);
|
|
147
|
+
return parsed.success ? parsed.data : null;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/** A row from before built avatars has no kind: a stored photo is active, otherwise initials. */
|
|
151
|
+
const userKind = (row: AvatarRow): AvatarKind => row.avatar_kind ?? (row.avatar_data_url ? "photo" : "initials");
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* A service account always shows a picture: its photo, or its robot, drawn from the account id until customised. The
|
|
155
|
+
* seed is the id as stored, so every spelling of the id in a URL draws the same robot.
|
|
156
|
+
*/
|
|
157
|
+
const serviceAccountRobot = (row: AvatarRow): RobotAvatar => parseRobot(row.avatar_config) ?? robotAvatarFromSeed(row.id);
|
|
158
|
+
|
|
159
|
+
const readUserRow = async (db: typeof sql, id: string, lock = false): Promise<AvatarRow | undefined> => {
|
|
160
|
+
const [row] = lock
|
|
161
|
+
? await db<AvatarRow[]>`
|
|
162
|
+
SELECT id, avatar_kind, avatar_config, avatar_data_url, avatar_hash FROM auth.users WHERE id = ${id}::uuid FOR UPDATE
|
|
163
|
+
`
|
|
164
|
+
: await db<AvatarRow[]>`SELECT id, avatar_kind, avatar_config, avatar_data_url, avatar_hash FROM auth.users WHERE id = ${id}::uuid`;
|
|
165
|
+
return row;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const readServiceAccountRow = async (db: typeof sql, id: string, lock = false): Promise<AvatarRow | undefined> => {
|
|
169
|
+
const [row] = lock
|
|
170
|
+
? await db<AvatarRow[]>`
|
|
171
|
+
SELECT id, avatar_kind, avatar_config, avatar_data_url, avatar_hash FROM auth.service_accounts WHERE id = ${id}::uuid FOR UPDATE
|
|
172
|
+
`
|
|
173
|
+
: await db<AvatarRow[]>`
|
|
174
|
+
SELECT id, avatar_kind, avatar_config, avatar_data_url, avatar_hash FROM auth.service_accounts WHERE id = ${id}::uuid
|
|
175
|
+
`;
|
|
176
|
+
return row;
|
|
177
|
+
};
|
|
98
178
|
|
|
179
|
+
/**
|
|
180
|
+
* The image others see for a person or a service account, or null when a person shows initials. An inactive photo is
|
|
181
|
+
* never served.
|
|
182
|
+
*/
|
|
183
|
+
export const getAvatar = async (params: { id: string }): Promise<ServedAvatar | null> => {
|
|
184
|
+
const user = await readUserRow(sql, params.id);
|
|
185
|
+
if (user) {
|
|
186
|
+
const kind = userKind(user);
|
|
187
|
+
if (kind === "photo") return photoAvatar(user);
|
|
188
|
+
if (kind === "built") {
|
|
189
|
+
// The served hash comes from the renderer drawing these bytes, not from storage: while a rollout mixes renderer
|
|
190
|
+
// revisions, a stored hash of another revision never marks these bytes as immutable.
|
|
191
|
+
const config = parsePerson(user.avatar_config);
|
|
192
|
+
return config ? svgAvatar(config, builtAvatarHash(config)) : null;
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
const account = await readServiceAccountRow(sql, params.id);
|
|
197
|
+
if (!account) return null;
|
|
198
|
+
if (account.avatar_kind === "photo") return photoAvatar(account);
|
|
199
|
+
const robot = serviceAccountRobot(account);
|
|
200
|
+
return svgAvatar(robot, builtAvatarHash(robot));
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const userState = (row: AvatarRow): AvatarState<PersonAvatar> => ({
|
|
204
|
+
kind: userKind(row),
|
|
205
|
+
built: parsePerson(row.avatar_config),
|
|
206
|
+
hasPhoto: row.avatar_data_url !== null,
|
|
207
|
+
photo: row.avatar_data_url,
|
|
208
|
+
avatarHash: row.avatar_hash,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const serviceAccountState = (row: AvatarRow): AvatarState<RobotAvatar> => {
|
|
212
|
+
const robot = serviceAccountRobot(row);
|
|
213
|
+
return {
|
|
214
|
+
kind: row.avatar_kind === "photo" ? "photo" : "built",
|
|
215
|
+
built: robot,
|
|
216
|
+
hasPhoto: row.avatar_data_url !== null,
|
|
217
|
+
photo: row.avatar_data_url,
|
|
218
|
+
avatarHash: row.avatar_kind === "photo" ? row.avatar_hash : (row.avatar_hash ?? builtAvatarHash(robot)),
|
|
219
|
+
};
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
export const getAvatarState = async (params: { id: string }): Promise<AvatarState<PersonAvatar> | null> => {
|
|
223
|
+
const row = await readUserRow(sql, params.id);
|
|
224
|
+
return row ? userState(row) : null;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
export const getServiceAccountAvatarState = async (params: { id: string }): Promise<AvatarState<RobotAvatar> | null> => {
|
|
228
|
+
const row = await readServiceAccountRow(sql, params.id);
|
|
229
|
+
return row ? serviceAccountState(row) : null;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const strippedPhoto = (dataUrl: string): MutationResult<{ dataUrl: string; hash: string }> => {
|
|
233
|
+
const parsed = parseAvatarDataUrl(dataUrl);
|
|
234
|
+
if (!parsed.ok) return parsed;
|
|
99
235
|
const bytes = Buffer.from(stripImageMetadata(parsed.data.bytes));
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if (
|
|
122
|
-
|
|
236
|
+
const stripped = `data:${parsed.data.contentType};base64,${bytes.toString("base64")}`;
|
|
237
|
+
return { ok: true, data: { dataUrl: stripped, hash: hashAvatar(stripped) } };
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const notFound = { ok: false, error: "User not found", status: 404 } as const;
|
|
241
|
+
const missing = (what: string) => ({ ok: false, error: `No ${what} is stored to show.`, status: 409 }) as const;
|
|
242
|
+
|
|
243
|
+
/** A change whose photo is already stripped; stripping happens before any storage access and throws a typed 422. */
|
|
244
|
+
type PreparedChange = { photo: { dataUrl: string; hash: string } } | Exclude<AvatarChange, { dataUrl: string }>;
|
|
245
|
+
|
|
246
|
+
const prepareChange = (change: AvatarChange): MutationResult<PreparedChange> => {
|
|
247
|
+
if (!("dataUrl" in change)) return { ok: true, data: change };
|
|
248
|
+
const photo = strippedPhoto(change.dataUrl);
|
|
249
|
+
return photo.ok ? { ok: true, data: { photo: photo.data } } : photo;
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
/** Applies one dialog save to a person: the new values for the avatar columns, or why the save cannot happen. */
|
|
253
|
+
const nextUserColumns = (row: AvatarRow, change: PreparedChange): MutationResult<AvatarRow> => {
|
|
254
|
+
if ("photo" in change) {
|
|
255
|
+
return { ok: true, data: { ...row, avatar_kind: "photo", avatar_data_url: change.photo.dataUrl, avatar_hash: change.photo.hash } };
|
|
256
|
+
}
|
|
257
|
+
if ("built" in change) {
|
|
258
|
+
const config = parsePerson(change.built);
|
|
259
|
+
if (!config) return { ok: false, error: "A person's avatar must use the person shape set.", status: 400 };
|
|
260
|
+
return { ok: true, data: { ...row, avatar_kind: "built", avatar_config: config, avatar_hash: builtAvatarHash(config) } };
|
|
261
|
+
}
|
|
262
|
+
if (change.use === "photo") {
|
|
263
|
+
if (!row.avatar_data_url) return missing("photo");
|
|
264
|
+
return { ok: true, data: { ...row, avatar_kind: "photo", avatar_hash: hashAvatar(row.avatar_data_url) } };
|
|
265
|
+
}
|
|
266
|
+
if (change.use === "built") {
|
|
267
|
+
const config = parsePerson(row.avatar_config);
|
|
268
|
+
if (!config) return missing("avatar");
|
|
269
|
+
return { ok: true, data: { ...row, avatar_kind: "built", avatar_hash: builtAvatarHash(config) } };
|
|
270
|
+
}
|
|
271
|
+
return { ok: true, data: { ...row, avatar_kind: "initials", avatar_hash: null } };
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const nextServiceAccountColumns = (row: AvatarRow, change: PreparedChange): MutationResult<AvatarRow> => {
|
|
275
|
+
if ("photo" in change) {
|
|
276
|
+
return { ok: true, data: { ...row, avatar_kind: "photo", avatar_data_url: change.photo.dataUrl, avatar_hash: change.photo.hash } };
|
|
277
|
+
}
|
|
278
|
+
if ("built" in change) {
|
|
279
|
+
const config = parseRobot(change.built);
|
|
280
|
+
if (!config) return { ok: false, error: "A service account's avatar must use the robot shape set.", status: 400 };
|
|
281
|
+
return { ok: true, data: { ...row, avatar_kind: "built", avatar_config: config, avatar_hash: builtAvatarHash(config) } };
|
|
282
|
+
}
|
|
283
|
+
if (change.use === "photo") {
|
|
284
|
+
if (!row.avatar_data_url) return missing("photo");
|
|
285
|
+
return { ok: true, data: { ...row, avatar_kind: "photo", avatar_hash: hashAvatar(row.avatar_data_url) } };
|
|
286
|
+
}
|
|
287
|
+
if (change.use === "built") {
|
|
288
|
+
const config = parseRobot(row.avatar_config);
|
|
289
|
+
return { ok: true, data: { ...row, avatar_kind: "built", avatar_hash: config ? builtAvatarHash(config) : null } };
|
|
290
|
+
}
|
|
291
|
+
return { ok: false, error: "A service account always shows a picture: a photo or its robot.", status: 400 };
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const writeUserColumns = (db: typeof sql, id: string, row: AvatarRow) => db`
|
|
295
|
+
UPDATE auth.users
|
|
296
|
+
SET avatar_kind = ${row.avatar_kind},
|
|
297
|
+
avatar_config = ${row.avatar_config === null ? null : JSON.stringify(row.avatar_config)}::text::jsonb,
|
|
298
|
+
avatar_data_url = ${row.avatar_data_url},
|
|
299
|
+
avatar_hash = ${row.avatar_hash}
|
|
300
|
+
WHERE id = ${id}::uuid
|
|
301
|
+
`;
|
|
302
|
+
|
|
303
|
+
const writeServiceAccountColumns = (db: typeof sql, id: string, row: AvatarRow) => db`
|
|
304
|
+
UPDATE auth.service_accounts
|
|
305
|
+
SET avatar_kind = ${row.avatar_kind},
|
|
306
|
+
avatar_config = ${row.avatar_config === null ? null : JSON.stringify(row.avatar_config)}::text::jsonb,
|
|
307
|
+
avatar_data_url = ${row.avatar_data_url},
|
|
308
|
+
avatar_hash = ${row.avatar_hash}
|
|
309
|
+
WHERE id = ${id}::uuid
|
|
310
|
+
`;
|
|
311
|
+
|
|
312
|
+
/** Saves a person's profile picture: a photo, a built avatar, or which stored picture others see. */
|
|
313
|
+
export const updateAvatar = async (params: { id: string; change: AvatarChange }): Promise<MutationResult<AvatarState<PersonAvatar>>> => {
|
|
314
|
+
const change = prepareChange(params.change);
|
|
315
|
+
if (!change.ok) return change;
|
|
316
|
+
return sql.begin(async (tx) => {
|
|
317
|
+
const row = await readUserRow(tx, params.id, true);
|
|
318
|
+
if (!row) return notFound;
|
|
319
|
+
const next = nextUserColumns(row, change.data);
|
|
320
|
+
if (!next.ok) return next;
|
|
321
|
+
await writeUserColumns(tx, params.id, next.data);
|
|
322
|
+
return { ok: true, data: userState(next.data) };
|
|
323
|
+
});
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
/** Saves a service account's picture: a photo, a customised robot, or which of them others see. */
|
|
327
|
+
export const updateServiceAccountAvatar = async (params: {
|
|
328
|
+
id: string;
|
|
329
|
+
change: AvatarChange;
|
|
330
|
+
}): Promise<MutationResult<AvatarState<RobotAvatar>>> => {
|
|
331
|
+
const change = prepareChange(params.change);
|
|
332
|
+
if (!change.ok) return change;
|
|
333
|
+
return sql.begin(async (tx) => {
|
|
334
|
+
const row = await readServiceAccountRow(tx, params.id, true);
|
|
335
|
+
if (!row) return { ok: false, error: "Service account not found", status: 404 };
|
|
336
|
+
const next = nextServiceAccountColumns(row, change.data);
|
|
337
|
+
if (!next.ok) return next;
|
|
338
|
+
await writeServiceAccountColumns(tx, params.id, next.data);
|
|
339
|
+
return { ok: true, data: serviceAccountState(next.data) };
|
|
340
|
+
});
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
/** Stores and shows a photo. Kept for the photo-only callers; same as `updateAvatar` with `{ dataUrl }`. */
|
|
344
|
+
export const setAvatar = async (params: { id: string; dataUrl: string }): Promise<MutationResult<{ avatarHash: string }>> => {
|
|
345
|
+
const result = await updateAvatar({ id: params.id, change: { dataUrl: params.dataUrl } });
|
|
346
|
+
if (!result.ok) return result;
|
|
347
|
+
return { ok: true, data: { avatarHash: result.data.avatarHash ?? "" } };
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
/** Deletes a person's photo. When the photo was showing, initials show instead; a shown built avatar stays. */
|
|
351
|
+
export const clearAvatar = async (params: { id: string }): Promise<MutationResult<void>> =>
|
|
352
|
+
sql.begin(async (tx) => {
|
|
353
|
+
const row = await readUserRow(tx, params.id, true);
|
|
354
|
+
if (!row) return notFound;
|
|
355
|
+
const showingPhoto = userKind(row) === "photo";
|
|
356
|
+
await writeUserColumns(tx, params.id, {
|
|
357
|
+
...row,
|
|
358
|
+
avatar_kind: showingPhoto ? "initials" : row.avatar_kind,
|
|
359
|
+
avatar_data_url: null,
|
|
360
|
+
avatar_hash: showingPhoto ? null : row.avatar_hash,
|
|
361
|
+
});
|
|
362
|
+
return { ok: true, data: undefined };
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
/** Deletes a service account's photo; its robot shows again. */
|
|
366
|
+
export const clearServiceAccountAvatar = async (params: { id: string }): Promise<MutationResult<AvatarState<RobotAvatar>>> =>
|
|
367
|
+
sql.begin(async (tx) => {
|
|
368
|
+
const row = await readServiceAccountRow(tx, params.id, true);
|
|
369
|
+
if (!row) return { ok: false, error: "Service account not found", status: 404 };
|
|
370
|
+
const config = parseRobot(row.avatar_config);
|
|
371
|
+
const next: AvatarRow = { ...row, avatar_kind: "built", avatar_data_url: null, avatar_hash: config ? builtAvatarHash(config) : null };
|
|
372
|
+
await writeServiceAccountColumns(tx, params.id, next);
|
|
373
|
+
return { ok: true, data: serviceAccountState(next) };
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
const REFRESH_BATCH = 500;
|
|
377
|
+
|
|
378
|
+
type RefreshRow = { id: string; avatar_config: unknown; avatar_hash: string | null };
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Writes the current renderer's hash for one stored built avatar, only if the row still holds the config and hash
|
|
382
|
+
* that were read: a save on another replica in between wins and keeps its own hash.
|
|
383
|
+
*/
|
|
384
|
+
export const writeRefreshedHash = async (db: typeof sql, table: "users" | "service_accounts", row: RefreshRow): Promise<boolean> => {
|
|
385
|
+
const config = table === "users" ? parsePerson(row.avatar_config) : parseRobot(row.avatar_config);
|
|
386
|
+
const hash = config ? builtAvatarHash(config) : null;
|
|
387
|
+
if (!hash || hash === row.avatar_hash) return false;
|
|
388
|
+
const configText = JSON.stringify(row.avatar_config);
|
|
389
|
+
const written =
|
|
390
|
+
table === "users"
|
|
391
|
+
? await db`
|
|
392
|
+
UPDATE auth.users SET avatar_hash = ${hash}
|
|
393
|
+
WHERE id = ${row.id}::uuid AND avatar_kind = 'built'
|
|
394
|
+
AND avatar_config = ${configText}::text::jsonb AND avatar_hash IS NOT DISTINCT FROM ${row.avatar_hash}
|
|
395
|
+
`
|
|
396
|
+
: await db`
|
|
397
|
+
UPDATE auth.service_accounts SET avatar_hash = ${hash}
|
|
398
|
+
WHERE id = ${row.id}::uuid AND COALESCE(avatar_kind, 'built') = 'built'
|
|
399
|
+
AND avatar_config = ${configText}::text::jsonb AND avatar_hash IS NOT DISTINCT FROM ${row.avatar_hash}
|
|
400
|
+
`;
|
|
401
|
+
return written.count > 0;
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Re-hashes stored built avatars after the renderer revision changed, so every client fetches the redrawn image. Runs
|
|
406
|
+
* at startup in bounded batches and writes only rows whose hash differs.
|
|
407
|
+
*/
|
|
408
|
+
export const refreshBuiltAvatarHashes = async (db: typeof sql = sql): Promise<number> => {
|
|
409
|
+
let updated = 0;
|
|
410
|
+
for (const table of ["users", "service_accounts"] as const) {
|
|
411
|
+
let after = "00000000-0000-0000-0000-000000000000";
|
|
412
|
+
for (;;) {
|
|
413
|
+
const rows =
|
|
414
|
+
table === "users"
|
|
415
|
+
? await db<RefreshRow[]>`
|
|
416
|
+
SELECT id, avatar_config, avatar_hash FROM auth.users
|
|
417
|
+
WHERE avatar_kind = 'built' AND id > ${after}::uuid ORDER BY id LIMIT ${REFRESH_BATCH}
|
|
418
|
+
`
|
|
419
|
+
: await db<RefreshRow[]>`
|
|
420
|
+
SELECT id, avatar_config, avatar_hash FROM auth.service_accounts
|
|
421
|
+
WHERE COALESCE(avatar_kind, 'built') = 'built' AND avatar_config IS NOT NULL AND id > ${after}::uuid
|
|
422
|
+
ORDER BY id LIMIT ${REFRESH_BATCH}
|
|
423
|
+
`;
|
|
424
|
+
for (const row of rows) if (await writeRefreshedHash(db, table, row)) updated++;
|
|
425
|
+
if (rows.length < REFRESH_BATCH) break;
|
|
426
|
+
after = rows.at(-1)?.id ?? after;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return updated;
|
|
123
430
|
};
|
|
@@ -4,8 +4,7 @@ import type { BaseGroup, UserProvider } from "../../contracts/shared";
|
|
|
4
4
|
type DbRow = Record<string, unknown>;
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* Joins the owner of a personal Linux group
|
|
8
|
-
* `auth.user_posix.primary_group_id`) onto `auth.groups g` as
|
|
7
|
+
* Joins the durable owner of a personal Linux group onto `auth.groups g` as
|
|
9
8
|
* `personal_owner_id`, `personal_owner_uid`, and `personal_owner_display_name`.
|
|
10
9
|
* A function, so importing this module never binds Bun's default `sql` handle.
|
|
11
10
|
*/
|
|
@@ -15,9 +14,9 @@ export const personalOwnerJoin = () => sql`
|
|
|
15
14
|
owner.id AS personal_owner_id,
|
|
16
15
|
owner.uid AS personal_owner_uid,
|
|
17
16
|
COALESCE(NULLIF(owner.display_name, ''), NULLIF(owner.mail, ''), owner.uid) AS personal_owner_display_name
|
|
18
|
-
FROM auth.
|
|
19
|
-
JOIN auth.users owner ON owner.id =
|
|
20
|
-
WHERE
|
|
17
|
+
FROM auth.personal_groups owner_group
|
|
18
|
+
JOIN auth.users owner ON owner.id = owner_group.owner_id
|
|
19
|
+
WHERE owner_group.group_id = g.id AND owner_group.retired_at IS NULL
|
|
21
20
|
ORDER BY owner.id
|
|
22
21
|
LIMIT 1
|
|
23
22
|
) personal_owner ON TRUE
|