@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,338 @@
|
|
|
1
|
+
import { isPromise } from "node:util/types";
|
|
2
|
+
import type { ReservedSQL, SQL, TransactionSQL } from "bun";
|
|
3
|
+
import * as bun from "bun";
|
|
4
|
+
import { REQUEST_IDLE_TIMEOUT_SECONDS } from "../server/request-timeout";
|
|
5
|
+
import { boundTransaction } from "./authority";
|
|
6
|
+
import { AppPwaError } from "./contracts";
|
|
7
|
+
|
|
8
|
+
const requestBudget = REQUEST_IDLE_TIMEOUT_SECONDS * 1000;
|
|
9
|
+
|
|
10
|
+
/** Bun exposes normalized options: timeout values are milliseconds and PostgreSQL
|
|
11
|
+
* startup parameters are NUL-separated in `query`. Reconstitute constructor input
|
|
12
|
+
* without losing role, routing or TLS settings. Never log these credential-bearing options.
|
|
13
|
+
*/
|
|
14
|
+
const cancellationPool = (db: SQL) => {
|
|
15
|
+
const options = db.options;
|
|
16
|
+
const query: unknown = Reflect.get(options, "query");
|
|
17
|
+
const sslMode: unknown = Reflect.get(options, "sslMode");
|
|
18
|
+
const modes = ["disable", "prefer", "require", "verify-ca", "verify-full"];
|
|
19
|
+
if (options.adapter !== "postgres" || typeof query !== "string" || typeof sslMode !== "number" || !modes[sslMode])
|
|
20
|
+
throw new AppPwaError("UNAVAILABLE", 503);
|
|
21
|
+
const parts = query.split("\0");
|
|
22
|
+
const connection: Record<string, string | number> = {};
|
|
23
|
+
for (let index = 0; index + 1 < parts.length; index += 2) connection[parts[index]!] = parts[index + 1]!;
|
|
24
|
+
connection.statement_timeout = requestBudget;
|
|
25
|
+
return new bun.SQL(`postgres://localhost?sslmode=${modes[sslMode]}`, {
|
|
26
|
+
...options,
|
|
27
|
+
// Bun adds SNI to normalized prefer options. Passing that generated object
|
|
28
|
+
// back as explicit TLS upgrades prefer to require. Recreate its default SNI
|
|
29
|
+
// from hostname instead; explicit verification options remain unchanged.
|
|
30
|
+
tls: sslMode === 1 ? undefined : options.tls,
|
|
31
|
+
connection,
|
|
32
|
+
max: 1,
|
|
33
|
+
connectionTimeout: REQUEST_IDLE_TIMEOUT_SECONDS,
|
|
34
|
+
idleTimeout: REQUEST_IDLE_TIMEOUT_SECONDS,
|
|
35
|
+
maxLifetime: REQUEST_IDLE_TIMEOUT_SECONDS,
|
|
36
|
+
onconnect: undefined,
|
|
37
|
+
onclose: undefined,
|
|
38
|
+
});
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Close application SQL independently of the platform's transaction lifecycle.
|
|
42
|
+
* Queries are lazy in Bun: guard execution as well as query construction, and
|
|
43
|
+
* settle started queries before the connection can be committed or reused.
|
|
44
|
+
*/
|
|
45
|
+
export const withAppPwaTransactionLease = async <T>(
|
|
46
|
+
connection: SQL,
|
|
47
|
+
signal: AbortSignal,
|
|
48
|
+
work: (tx: TransactionSQL) => Promise<T>,
|
|
49
|
+
beforeQuery: () => Promise<void> = async () => {},
|
|
50
|
+
): Promise<T> => {
|
|
51
|
+
let open = true;
|
|
52
|
+
let sequence = 0;
|
|
53
|
+
let queue = Promise.resolve();
|
|
54
|
+
const queries = new Set<{ cancel(): void; pending?: Promise<unknown> }>();
|
|
55
|
+
const guardedQueries = new WeakMap<object, object>();
|
|
56
|
+
const check = () => {
|
|
57
|
+
if (!open || signal.aborted) throw new AppPwaError("UNAVAILABLE", 503);
|
|
58
|
+
};
|
|
59
|
+
const guardResult = (result: unknown, recovery = false): unknown => {
|
|
60
|
+
if (typeof result !== "object" || result === null || !("cancel" in result) || typeof result.cancel !== "function") return result;
|
|
61
|
+
const existing = guardedQueries.get(result);
|
|
62
|
+
if (existing) return existing;
|
|
63
|
+
// ReservedSQL.close rejects even never-started Query promises. Observe the
|
|
64
|
+
// native promise without invoking Query.then(), which would execute lazy SQL.
|
|
65
|
+
if (isPromise(result)) void Promise.prototype.then.call(result, undefined, () => {});
|
|
66
|
+
const cancel = result.cancel;
|
|
67
|
+
const query: { cancel(): void; pending?: Promise<unknown> } = { cancel: () => Reflect.apply(cancel, result, []) };
|
|
68
|
+
queries.add(query);
|
|
69
|
+
const start = (): Promise<unknown> => {
|
|
70
|
+
check();
|
|
71
|
+
if (!query.pending) {
|
|
72
|
+
query.pending = queue.then(async () => {
|
|
73
|
+
check();
|
|
74
|
+
// An aborted savepoint rejects ordinary SQL (including set_config).
|
|
75
|
+
// ROLLBACK TO must reach PostgreSQL before configuration can resume.
|
|
76
|
+
if (!recovery) await beforeQuery();
|
|
77
|
+
check();
|
|
78
|
+
// Assimilate the original Bun query only after setting its remaining budget.
|
|
79
|
+
return await result;
|
|
80
|
+
});
|
|
81
|
+
queue = query.pending.then(
|
|
82
|
+
() => {},
|
|
83
|
+
() => {},
|
|
84
|
+
);
|
|
85
|
+
void query.pending.then(
|
|
86
|
+
() => queries.delete(query),
|
|
87
|
+
() => queries.delete(query),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return query.pending;
|
|
91
|
+
};
|
|
92
|
+
const guarded = new Proxy(result, {
|
|
93
|
+
get(target, property) {
|
|
94
|
+
check();
|
|
95
|
+
if (property === "then") return (yes: (value: unknown) => unknown, no: (error: unknown) => unknown) => start().then(yes, no);
|
|
96
|
+
if (property === "catch") return (no: (error: unknown) => unknown) => start().catch(no);
|
|
97
|
+
if (property === "finally") return (done: () => void) => start().finally(done);
|
|
98
|
+
if (property === "execute")
|
|
99
|
+
return () => {
|
|
100
|
+
void start();
|
|
101
|
+
return guarded;
|
|
102
|
+
};
|
|
103
|
+
const value: unknown = Reflect.get(target, property, target);
|
|
104
|
+
if (typeof value !== "function") return value;
|
|
105
|
+
return (...args: unknown[]) => {
|
|
106
|
+
check();
|
|
107
|
+
return guardResult(Reflect.apply(value, target, args));
|
|
108
|
+
};
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
guardedQueries.set(result, guarded);
|
|
112
|
+
return guarded;
|
|
113
|
+
};
|
|
114
|
+
function savepoint<T>(nameOrWork: string | SQL.SavepointContextCallback<T>, callback?: SQL.SavepointContextCallback<T>): Promise<T>;
|
|
115
|
+
async function savepoint(
|
|
116
|
+
nameOrWork: string | SQL.SavepointContextCallback<unknown>,
|
|
117
|
+
callback?: SQL.SavepointContextCallback<unknown>,
|
|
118
|
+
): Promise<unknown> {
|
|
119
|
+
check();
|
|
120
|
+
const callbackWork = typeof nameOrWork === "function" ? nameOrWork : callback;
|
|
121
|
+
if (!callbackWork) throw new TypeError("A savepoint needs a callback");
|
|
122
|
+
// Only generated identifiers enter SQL; the optional name is diagnostic in Bun.
|
|
123
|
+
const name = `app_pwa_${sequence++}`;
|
|
124
|
+
await tx.unsafe(`SAVEPOINT ${name}`);
|
|
125
|
+
try {
|
|
126
|
+
const result = await callbackWork(tx);
|
|
127
|
+
// Bun starts lazy query arrays returned by a transaction callback. Settle
|
|
128
|
+
// them before releasing the savepoint, including its rollback on failure.
|
|
129
|
+
const settled = Array.isArray(result) ? await Promise.all(result) : result;
|
|
130
|
+
await tx.unsafe(`RELEASE SAVEPOINT ${name}`);
|
|
131
|
+
return settled;
|
|
132
|
+
} catch (error) {
|
|
133
|
+
// A late callback must never roll back a later user's transaction.
|
|
134
|
+
check();
|
|
135
|
+
await guardResult(connection.unsafe(`ROLLBACK TO SAVEPOINT ${name}`), true);
|
|
136
|
+
await tx.unsafe(`RELEASE SAVEPOINT ${name}`);
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const tx: TransactionSQL = new Proxy(Object.assign(connection, { savepoint }), {
|
|
141
|
+
apply(target, receiver, args) {
|
|
142
|
+
check();
|
|
143
|
+
return guardResult(Reflect.apply(target, receiver, args));
|
|
144
|
+
},
|
|
145
|
+
get(target, property) {
|
|
146
|
+
check();
|
|
147
|
+
if (property === "file")
|
|
148
|
+
return async (path: string, args?: unknown[]) => {
|
|
149
|
+
const text = await Bun.file(path).text();
|
|
150
|
+
check();
|
|
151
|
+
return await tx.unsafe(text, args);
|
|
152
|
+
};
|
|
153
|
+
const value: unknown = Reflect.get(target, property, target);
|
|
154
|
+
if (typeof value !== "function") return value;
|
|
155
|
+
if (!["unsafe", "array", "savepoint"].includes(String(property)))
|
|
156
|
+
return () => {
|
|
157
|
+
throw new AppPwaError("FORBIDDEN", 403);
|
|
158
|
+
};
|
|
159
|
+
return (...args: unknown[]) => {
|
|
160
|
+
check();
|
|
161
|
+
return guardResult(Reflect.apply(value, target, args));
|
|
162
|
+
};
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
const close = () => {
|
|
166
|
+
if (!open) return;
|
|
167
|
+
open = false;
|
|
168
|
+
for (const query of queries) {
|
|
169
|
+
try {
|
|
170
|
+
query.cancel();
|
|
171
|
+
} catch {
|
|
172
|
+
/* Transaction recovery owns a failed cancellation. */
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
let abort = () => {};
|
|
177
|
+
const cancelled = new Promise<never>((_resolve, reject) => {
|
|
178
|
+
abort = () => {
|
|
179
|
+
close();
|
|
180
|
+
reject(new AppPwaError("UNAVAILABLE", 503));
|
|
181
|
+
};
|
|
182
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
183
|
+
});
|
|
184
|
+
try {
|
|
185
|
+
check();
|
|
186
|
+
return await Promise.race([work(tx), cancelled]);
|
|
187
|
+
} finally {
|
|
188
|
+
close();
|
|
189
|
+
await Promise.allSettled([...queries].flatMap((query) => (query.pending ? [query.pending] : [])));
|
|
190
|
+
signal.removeEventListener("abort", abort);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/** Own BEGIN through COMMIT/rollback, including server-side cancellation.
|
|
195
|
+
* Reservation cancellation alone stops having an effect once reserve resolves.
|
|
196
|
+
*/
|
|
197
|
+
export const withAppPwaTransaction = async <T>(
|
|
198
|
+
db: SQL,
|
|
199
|
+
signal: AbortSignal | undefined,
|
|
200
|
+
work: (tx: TransactionSQL, signal: AbortSignal) => Promise<T>,
|
|
201
|
+
authorityDeadline: (time: Date) => Date | undefined = () => undefined,
|
|
202
|
+
): Promise<T> => {
|
|
203
|
+
const requestEnd = performance.now() + requestBudget;
|
|
204
|
+
const expiry = new AbortController();
|
|
205
|
+
const requestSignal = AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(requestBudget), expiry.signal]);
|
|
206
|
+
const connection = await db.reserve({ signal: requestSignal });
|
|
207
|
+
let authorityEnd = Infinity;
|
|
208
|
+
let expiryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
209
|
+
let recoveryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
210
|
+
let closing: Promise<void> | undefined;
|
|
211
|
+
let recovering = false;
|
|
212
|
+
let backend: { pid: number; started: string } | undefined;
|
|
213
|
+
let cancellation: Promise<void> | undefined;
|
|
214
|
+
let control: ReservedSQL | undefined;
|
|
215
|
+
const failure = () =>
|
|
216
|
+
expiry.signal.aborted || (authorityEnd <= requestEnd && performance.now() >= authorityEnd)
|
|
217
|
+
? new AppPwaError("EXPIRED", 410)
|
|
218
|
+
: new AppPwaError("UNAVAILABLE", 503);
|
|
219
|
+
const check = () => {
|
|
220
|
+
if (requestSignal.aborted || performance.now() >= Math.min(requestEnd, authorityEnd)) throw failure();
|
|
221
|
+
};
|
|
222
|
+
const closeConnection = () => {
|
|
223
|
+
closing ??= connection.close({ timeout: 0 });
|
|
224
|
+
};
|
|
225
|
+
const startRecoveryTimer = () => {
|
|
226
|
+
recoveryTimer ??= setTimeout(() => {
|
|
227
|
+
closeConnection();
|
|
228
|
+
void control?.close({ timeout: 0 });
|
|
229
|
+
}, requestBudget);
|
|
230
|
+
};
|
|
231
|
+
const cancelBackend = async () => {
|
|
232
|
+
let pool: SQL | undefined;
|
|
233
|
+
try {
|
|
234
|
+
if (!backend) return closeConnection();
|
|
235
|
+
pool = cancellationPool(db);
|
|
236
|
+
// A separate max:1 pool cannot deadlock behind the reserved application pool.
|
|
237
|
+
control = await pool.reserve({ signal: AbortSignal.timeout(requestBudget) });
|
|
238
|
+
if (closing) return;
|
|
239
|
+
// pg_cancel_backend permits the authenticated owner's role. A startup SET
|
|
240
|
+
// ROLE may have narrowed current_user, so restore this connection's login.
|
|
241
|
+
await control`RESET ROLE`;
|
|
242
|
+
if (closing) return;
|
|
243
|
+
const [row] = await control<{ cancelled: boolean }[]>`SELECT pg_cancel_backend(pid) AS cancelled
|
|
244
|
+
FROM pg_stat_activity WHERE pid=${backend.pid} AND extract(epoch FROM backend_start)::text=${backend.started}
|
|
245
|
+
AND datname=current_database() AND usename=session_user`;
|
|
246
|
+
if (!row?.cancelled) closeConnection();
|
|
247
|
+
} catch {
|
|
248
|
+
// Unreachable control connection or denied cancellation: discard, never reuse.
|
|
249
|
+
closeConnection();
|
|
250
|
+
} finally {
|
|
251
|
+
await control?.close({ timeout: 0 });
|
|
252
|
+
// Pool close(timeout:0) is graceful in this Bun; a positive bound is required.
|
|
253
|
+
await pool?.close({ timeout: REQUEST_IDLE_TIMEOUT_SECONDS });
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
const abort = () => {
|
|
257
|
+
startRecoveryTimer();
|
|
258
|
+
// Query.cancel() does not send PostgreSQL cancellation on Bun 744846f84.
|
|
259
|
+
// Keep this reservation until the independent cancellation has settled.
|
|
260
|
+
if (!recovering) cancellation ??= cancelBackend().catch(closeConnection);
|
|
261
|
+
};
|
|
262
|
+
requestSignal.addEventListener("abort", abort, { once: true });
|
|
263
|
+
const run = async <R>(query: SQL.Query<R>): Promise<R> => {
|
|
264
|
+
if (!recovering) check();
|
|
265
|
+
return await query;
|
|
266
|
+
};
|
|
267
|
+
const configure = async () => {
|
|
268
|
+
check();
|
|
269
|
+
// PostgreSQL interprets zero as unlimited. Never round a remaining budget up.
|
|
270
|
+
const remaining = Math.floor(Math.min(requestEnd, authorityEnd) - performance.now());
|
|
271
|
+
if (remaining < 1) throw failure();
|
|
272
|
+
await run(boundTransaction(connection, remaining));
|
|
273
|
+
check();
|
|
274
|
+
};
|
|
275
|
+
const checkAuthority = async () => {
|
|
276
|
+
await configure();
|
|
277
|
+
const started = performance.now();
|
|
278
|
+
const [row] = await run(connection<{ time: Date }[]>`SELECT clock_timestamp() AS time`);
|
|
279
|
+
if (!row) throw new AppPwaError("UNAVAILABLE", 503);
|
|
280
|
+
const time = row.time;
|
|
281
|
+
const deadline = authorityDeadline(time);
|
|
282
|
+
if (deadline) {
|
|
283
|
+
// Bun Date values truncate PostgreSQL's submillisecond clock precision.
|
|
284
|
+
// Discount that precision as well as the clock query's round trip.
|
|
285
|
+
authorityEnd = Math.min(authorityEnd, started + deadline.getTime() - time.getTime() - 1);
|
|
286
|
+
clearTimeout(expiryTimer);
|
|
287
|
+
expiryTimer = setTimeout(() => expiry.abort(), Math.max(0, authorityEnd - performance.now()));
|
|
288
|
+
}
|
|
289
|
+
check();
|
|
290
|
+
};
|
|
291
|
+
try {
|
|
292
|
+
check();
|
|
293
|
+
[backend] = await run(connection<{ pid: number; started: string }[]>`SELECT pg_backend_pid() AS pid,
|
|
294
|
+
extract(epoch FROM backend_start)::text AS started FROM pg_stat_activity WHERE pid=pg_backend_pid()`);
|
|
295
|
+
if (!backend || !Number.isSafeInteger(backend.pid) || backend.pid <= 0 || !backend.started) throw failure();
|
|
296
|
+
await run(connection`BEGIN`);
|
|
297
|
+
const result = await withAppPwaTransactionLease(connection, requestSignal, (tx) => work(tx, requestSignal), configure);
|
|
298
|
+
await checkAuthority();
|
|
299
|
+
await configure();
|
|
300
|
+
await run(connection`SET CONSTRAINTS ALL IMMEDIATE`);
|
|
301
|
+
// All application callbacks and deferred constraint work precede this fresh clock.
|
|
302
|
+
await checkAuthority();
|
|
303
|
+
await configure();
|
|
304
|
+
await run(connection`COMMIT`);
|
|
305
|
+
// COMMIT may already be durable when an abort arrives. A known success stays
|
|
306
|
+
// successful; a lost acknowledgement is reconciled through the stored attempt.
|
|
307
|
+
return result;
|
|
308
|
+
} catch (error) {
|
|
309
|
+
recovering = true;
|
|
310
|
+
startRecoveryTimer();
|
|
311
|
+
// A late cancel must not interrupt rollback or a later transaction.
|
|
312
|
+
await cancellation;
|
|
313
|
+
try {
|
|
314
|
+
await run(connection`ROLLBACK`);
|
|
315
|
+
} catch {
|
|
316
|
+
closeConnection();
|
|
317
|
+
}
|
|
318
|
+
if (requestSignal.aborted || performance.now() >= Math.min(requestEnd, authorityEnd)) throw failure();
|
|
319
|
+
throw error;
|
|
320
|
+
} finally {
|
|
321
|
+
// Never return a connection while COMMIT, cancellation or recovery is pending.
|
|
322
|
+
// SQL has settled. Stop accepting new cancellation before snapshotting its
|
|
323
|
+
// promise: an abort during `await undefined` must not escape cleanup.
|
|
324
|
+
requestSignal.removeEventListener("abort", abort);
|
|
325
|
+
clearTimeout(expiryTimer);
|
|
326
|
+
try {
|
|
327
|
+
await cancellation;
|
|
328
|
+
if (requestSignal.aborted) closeConnection();
|
|
329
|
+
if (closing) await closing;
|
|
330
|
+
} finally {
|
|
331
|
+
clearTimeout(recoveryTimer);
|
|
332
|
+
// ReservedSQL.close resolves before its socket-close callback. That
|
|
333
|
+
// callback releases the reservation after marking the pool slot closed;
|
|
334
|
+
// explicitly releasing earlier could hand out a closing connection.
|
|
335
|
+
if (!closing) connection.release();
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { AppPwaError, pwaFail } from "./contracts";
|
|
2
|
+
|
|
3
|
+
/** Shared by body reads and the whole transport: stalled cancellation hooks never hold the response open. */
|
|
4
|
+
export const untilAppPwaAbort = async <T>(signal: AbortSignal, work: () => Promise<T>): Promise<T> => {
|
|
5
|
+
let abort = () => {};
|
|
6
|
+
const stopped = new Promise<never>((_resolve, reject) => {
|
|
7
|
+
abort = () => reject(new AppPwaError("UNAVAILABLE", 503));
|
|
8
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
9
|
+
});
|
|
10
|
+
try {
|
|
11
|
+
if (signal.aborted) return pwaFail("UNAVAILABLE", 503);
|
|
12
|
+
return await Promise.race([work(), stopped]);
|
|
13
|
+
} finally {
|
|
14
|
+
signal.removeEventListener("abort", abort);
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const readAppPwaJson = async (request: Request, maxBytes: number, signal: AbortSignal): Promise<unknown> => {
|
|
19
|
+
const reader = request.body?.getReader();
|
|
20
|
+
if (!reader) return pwaFail("INVALID_INPUT", 400);
|
|
21
|
+
let finished = false;
|
|
22
|
+
try {
|
|
23
|
+
return await untilAppPwaAbort(signal, async () => {
|
|
24
|
+
const declared = request.headers.get("content-length");
|
|
25
|
+
if (declared !== null && (!/^\d+$/.test(declared) || !Number.isSafeInteger(Number(declared)))) pwaFail("INVALID_INPUT", 400);
|
|
26
|
+
if (declared !== null && Number(declared) > maxBytes) pwaFail("TOO_LARGE", 413);
|
|
27
|
+
const chunks: Uint8Array[] = [];
|
|
28
|
+
let total = 0;
|
|
29
|
+
while (true) {
|
|
30
|
+
const next = await reader.read();
|
|
31
|
+
if (signal.aborted) pwaFail("UNAVAILABLE", 503);
|
|
32
|
+
if (next.done) break;
|
|
33
|
+
total += next.value.byteLength;
|
|
34
|
+
if (total > maxBytes) pwaFail("TOO_LARGE", 413);
|
|
35
|
+
chunks.push(next.value);
|
|
36
|
+
}
|
|
37
|
+
finished = true;
|
|
38
|
+
const bytes = new Uint8Array(total);
|
|
39
|
+
let offset = 0;
|
|
40
|
+
for (const chunk of chunks) {
|
|
41
|
+
bytes.set(chunk, offset);
|
|
42
|
+
offset += chunk.byteLength;
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
46
|
+
} catch {
|
|
47
|
+
return pwaFail("INVALID_INPUT", 400);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (error instanceof AppPwaError) throw error;
|
|
52
|
+
return pwaFail("INVALID_INPUT", 400);
|
|
53
|
+
} finally {
|
|
54
|
+
if (!finished) void reader.cancel().catch(() => undefined);
|
|
55
|
+
reader.releaseLock();
|
|
56
|
+
}
|
|
57
|
+
};
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { type Context, Hono } from "hono";
|
|
2
|
+
import { setCookie } from "hono/cookie";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { resolveLocale } from "../server/locale";
|
|
5
|
+
import type { AuthContext } from "../server/middleware/auth";
|
|
6
|
+
import { rateLimit } from "../server/middleware/rate-limit";
|
|
7
|
+
import { REQUEST_IDLE_TIMEOUT_SECONDS } from "../server/request-timeout";
|
|
8
|
+
import { pairingSecret } from "../services/pairing-secret";
|
|
9
|
+
import { session } from "../services/session";
|
|
10
|
+
import { get as getSetting } from "../services/settings";
|
|
11
|
+
import {
|
|
12
|
+
AppPwaAttempt,
|
|
13
|
+
AppPwaClaim,
|
|
14
|
+
AppPwaError,
|
|
15
|
+
AppPwaId,
|
|
16
|
+
type AppPwaOptions,
|
|
17
|
+
AppPwaSecret,
|
|
18
|
+
type AppPwaWebSignIn,
|
|
19
|
+
parseAppPwaInput as parse,
|
|
20
|
+
pwaFail,
|
|
21
|
+
} from "./contracts";
|
|
22
|
+
import { AppPwaCoreQueryError } from "./core-query-error";
|
|
23
|
+
import { type AppPwaService, createAppPwaService } from "./service";
|
|
24
|
+
import { readAppPwaJson, untilAppPwaAbort } from "./transport-input";
|
|
25
|
+
|
|
26
|
+
/** Cookie paths are not trustworthy in the Cookie header. Reject all ambiguous same-name credentials. */
|
|
27
|
+
export const appPwaCookie = (header: string | null, name: string): string | undefined => {
|
|
28
|
+
const entries = (header ?? "")
|
|
29
|
+
.split(";")
|
|
30
|
+
.map((value) => value.trim())
|
|
31
|
+
.filter((value) => value.split("=", 1)[0]?.trim() === name);
|
|
32
|
+
if (entries.length > 1) return pwaFail("DUPLICATE_COOKIE", 400);
|
|
33
|
+
const entry = entries[0];
|
|
34
|
+
if (!entry) return undefined;
|
|
35
|
+
const value = entry.slice(entry.indexOf("=") + 1);
|
|
36
|
+
if (!value || entry.indexOf("=") < 0) return pwaFail("BROWSER_REQUIRED", 401);
|
|
37
|
+
return value;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Only this surface's browser credential is considered; employee cookies/bearers are irrelevant. */
|
|
41
|
+
export const appPwaCredential = (headers: Headers, service: Pick<AppPwaService, "cookieName" | "csrf">): string => {
|
|
42
|
+
const cookie = appPwaCookie(headers.get("Cookie"), service.cookieName);
|
|
43
|
+
if (!cookie || !AppPwaSecret.safeParse(cookie).success) return pwaFail("BROWSER_REQUIRED", 401);
|
|
44
|
+
const received = headers.get("X-App-Pwa-CSRF");
|
|
45
|
+
if (!received || !pairingSecret.matches(received, pairingSecret.hash(service.csrf(cookie)))) return pwaFail("CSRF_REQUIRED", 403);
|
|
46
|
+
return cookie;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const empty = z.strictObject({});
|
|
50
|
+
const deviceIdInput = z.strictObject({ deviceId: AppPwaId });
|
|
51
|
+
const visitIdInput = z.strictObject({ visitId: AppPwaId });
|
|
52
|
+
const queryInput = z.strictObject({ visitId: AppPwaId, input: z.unknown() });
|
|
53
|
+
const commandInput = AppPwaAttempt.extend({ visitId: AppPwaId });
|
|
54
|
+
|
|
55
|
+
/** Closed HTTP adapter: application code can register only typed query/command/device operations, never middleware. */
|
|
56
|
+
export const createAppPwaRouter = (service: AppPwaService) => {
|
|
57
|
+
const guestBase = `${service.path}_api`;
|
|
58
|
+
const staffBase = `/api/${service.appId}/app-pwa`;
|
|
59
|
+
// AuthContext is used only to reuse platform rateLimit's IP mode. Guest handlers never read its identity slots.
|
|
60
|
+
const router = new Hono<AuthContext>();
|
|
61
|
+
const limiter = rateLimit({ keyBy: "ip" });
|
|
62
|
+
router.onError((error, c) => {
|
|
63
|
+
if (error instanceof AppPwaCoreQueryError)
|
|
64
|
+
return Response.json(
|
|
65
|
+
{ code: error.code, ...(error.details ? { details: error.details } : {}) },
|
|
66
|
+
{ status: error.status, headers: c.res.headers },
|
|
67
|
+
);
|
|
68
|
+
const safe = error instanceof AppPwaError ? error : new AppPwaError("UNAVAILABLE", 503);
|
|
69
|
+
return c.json({ code: safe.code }, safe.status);
|
|
70
|
+
});
|
|
71
|
+
router.notFound((c) => c.json({ code: "NOT_FOUND" }, 404));
|
|
72
|
+
router.use("*", async (c, next) => {
|
|
73
|
+
c.header("Cache-Control", "no-store, no-transform");
|
|
74
|
+
c.header("Pragma", "no-cache");
|
|
75
|
+
c.header("Referrer-Policy", "no-referrer");
|
|
76
|
+
c.header("X-Content-Type-Options", "nosniff");
|
|
77
|
+
// No CORS middleware and no OPTIONS success. All transport methods are JSON POST, including passive reads.
|
|
78
|
+
if (c.req.method !== "POST") return c.json({ code: "NOT_FOUND" }, 404);
|
|
79
|
+
if (c.req.header("Origin") !== service.publicOrigin) return pwaFail("ORIGIN_REQUIRED", 403);
|
|
80
|
+
if (!/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(c.req.header("Content-Type") ?? "")) return pwaFail("JSON_REQUIRED", 415);
|
|
81
|
+
return next();
|
|
82
|
+
});
|
|
83
|
+
router.use("*", async (c, next) => {
|
|
84
|
+
const response = await limiter(c, next);
|
|
85
|
+
return response instanceof Response && response.status === 429 ? c.json({ code: "RATE_LIMITED" }, 429) : response;
|
|
86
|
+
});
|
|
87
|
+
const body = (c: Context) => readAppPwaJson(c.req.raw, service.budgets.requestBytes, c.req.raw.signal);
|
|
88
|
+
const credential = (c: Context): string => appPwaCredential(c.req.raw.headers, service);
|
|
89
|
+
const web = async (c: Context): Promise<{ signIn: AppPwaWebSignIn; token: string }> => {
|
|
90
|
+
if (c.req.header("Authorization")) return pwaFail("WEB_SESSION_REQUIRED", 401);
|
|
91
|
+
appPwaCookie(c.req.header("Cookie") ?? null, "session_token");
|
|
92
|
+
const token = session.getWebToken(c);
|
|
93
|
+
const authenticated = token ? await session.authenticate(token) : null;
|
|
94
|
+
if (!token || !authenticated || authenticated.data.kind !== "web") return pwaFail("WEB_SESSION_REQUIRED", 401);
|
|
95
|
+
return { signIn: { userId: authenticated.user.id, sid: authenticated.data.sid }, token };
|
|
96
|
+
};
|
|
97
|
+
const staffCsrf = (token: string) => pairingSecret.hash(`cloud-app-pwa:staff:${service.appId}:${token}`);
|
|
98
|
+
const staff = async (c: Context): Promise<AppPwaWebSignIn> => {
|
|
99
|
+
const current = await web(c);
|
|
100
|
+
const received = c.req.header("X-App-Pwa-CSRF");
|
|
101
|
+
if (!received || !pairingSecret.matches(received, pairingSecret.hash(staffCsrf(current.token)))) return pwaFail("CSRF_REQUIRED", 403);
|
|
102
|
+
return current.signIn;
|
|
103
|
+
};
|
|
104
|
+
router.post(`${guestBase}/bootstrap`, async (c) => {
|
|
105
|
+
parse(empty, await body(c));
|
|
106
|
+
const boot = await service.bootstrap(appPwaCookie(c.req.header("Cookie") ?? null, service.cookieName), c.req.raw.signal);
|
|
107
|
+
setCookie(c, service.cookieName, boot.credential, {
|
|
108
|
+
secure: true,
|
|
109
|
+
httpOnly: true,
|
|
110
|
+
sameSite: "Strict",
|
|
111
|
+
path: service.path,
|
|
112
|
+
// Session cookie: the durable browser expiry is authoritative and is never renewed by bootstrap.
|
|
113
|
+
});
|
|
114
|
+
return c.json({ csrf: boot.csrf });
|
|
115
|
+
});
|
|
116
|
+
router.post(`${guestBase}/device/claim`, async (c) => {
|
|
117
|
+
const key = credential(c);
|
|
118
|
+
const input = parse(AppPwaClaim, await body(c));
|
|
119
|
+
return c.json(await service.claimDevice(key, input.qr, c.req.raw.signal));
|
|
120
|
+
});
|
|
121
|
+
router.post(`${guestBase}/visit/claim`, async (c) => {
|
|
122
|
+
const key = credential(c);
|
|
123
|
+
const input = parse(AppPwaClaim, await body(c));
|
|
124
|
+
return c.json(await service.claimVisit(key, input.qr, c.req.raw.signal));
|
|
125
|
+
});
|
|
126
|
+
router.post(`${guestBase}/status`, async (c) => {
|
|
127
|
+
const key = credential(c);
|
|
128
|
+
parse(empty, await body(c));
|
|
129
|
+
return c.json(await service.status(key, c.req.raw.signal));
|
|
130
|
+
});
|
|
131
|
+
router.post(`${guestBase}/visit/end`, async (c) => {
|
|
132
|
+
const key = credential(c);
|
|
133
|
+
const input = parse(visitIdInput, await body(c));
|
|
134
|
+
return c.json(await service.endVisit(key, input.visitId, c.req.raw.signal));
|
|
135
|
+
});
|
|
136
|
+
router.post(`${guestBase}/visit/attempt`, async (c) => {
|
|
137
|
+
const key = credential(c);
|
|
138
|
+
const input = parse(z.strictObject({ visitId: AppPwaId, attemptId: AppPwaId }), await body(c));
|
|
139
|
+
return c.json(await service.attempt(key, input.visitId, input.attemptId, c.req.raw.signal));
|
|
140
|
+
});
|
|
141
|
+
router.post(`${guestBase}/core/query/:query`, async (c) => {
|
|
142
|
+
const key = credential(c);
|
|
143
|
+
const input = parse(z.strictObject({ visitId: AppPwaId.optional(), input: z.unknown() }), await body(c));
|
|
144
|
+
return c.json(
|
|
145
|
+
await service.coreQuery(
|
|
146
|
+
key,
|
|
147
|
+
input.visitId,
|
|
148
|
+
c.req.param("query"),
|
|
149
|
+
input.input,
|
|
150
|
+
c.req.raw.signal,
|
|
151
|
+
resolveLocale(c.req.raw.headers, await getSetting<string>("app.locale")),
|
|
152
|
+
),
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
router.post(`${guestBase}/query/:route`, async (c) => {
|
|
156
|
+
const key = credential(c);
|
|
157
|
+
const input = parse(queryInput, await body(c));
|
|
158
|
+
return c.json(await service.query(key, input.visitId, c.req.param("route"), input.input, c.req.raw.signal));
|
|
159
|
+
});
|
|
160
|
+
router.post(`${guestBase}/command/:route`, async (c) => {
|
|
161
|
+
const key = credential(c);
|
|
162
|
+
const input = parse(commandInput, await body(c));
|
|
163
|
+
return c.json(await service.command(key, input.visitId, c.req.param("route"), input.attemptId, input.input, c.req.raw.signal));
|
|
164
|
+
});
|
|
165
|
+
router.post(`${guestBase}/device/:route`, async (c) => {
|
|
166
|
+
const key = credential(c);
|
|
167
|
+
const input = parse(z.strictObject({ input: z.unknown() }), await body(c));
|
|
168
|
+
return c.json(await service.deviceOperation(key, c.req.param("route"), input.input, c.req.raw.signal));
|
|
169
|
+
});
|
|
170
|
+
router.post(`${staffBase}/bootstrap`, async (c) => {
|
|
171
|
+
parse(empty, await body(c));
|
|
172
|
+
const current = await web(c);
|
|
173
|
+
// Authorization occurs on every real action, inside its service transaction.
|
|
174
|
+
return c.json({ csrf: staffCsrf(current.token) });
|
|
175
|
+
});
|
|
176
|
+
router.post(`${staffBase}/devices/issue`, async (c) => {
|
|
177
|
+
const signIn = await staff(c);
|
|
178
|
+
const input = parse(z.strictObject({ requestId: AppPwaId, name: z.string() }), await body(c));
|
|
179
|
+
return c.json(await service.issueDevice(signIn, input, c.req.raw.signal));
|
|
180
|
+
});
|
|
181
|
+
router.post(`${staffBase}/devices/list`, async (c) => {
|
|
182
|
+
const signIn = await staff(c);
|
|
183
|
+
const input = parse(z.strictObject({ after: AppPwaId.optional() }), await body(c));
|
|
184
|
+
return c.json(await service.listDevices(signIn, input.after, c.req.raw.signal));
|
|
185
|
+
});
|
|
186
|
+
router.post(`${staffBase}/devices/status`, async (c) => {
|
|
187
|
+
const signIn = await staff(c);
|
|
188
|
+
const input = parse(deviceIdInput, await body(c));
|
|
189
|
+
return c.json(await service.deviceStatus(signIn, input.deviceId, c.req.raw.signal));
|
|
190
|
+
});
|
|
191
|
+
router.post(`${staffBase}/devices/revoke`, async (c) => {
|
|
192
|
+
const signIn = await staff(c);
|
|
193
|
+
const input = parse(deviceIdInput, await body(c));
|
|
194
|
+
return c.json(await service.revokeDevice(signIn, input.deviceId, c.req.raw.signal));
|
|
195
|
+
});
|
|
196
|
+
router.post(`${staffBase}/visits/issue`, async (c) => {
|
|
197
|
+
const signIn = await staff(c);
|
|
198
|
+
const input = parse(z.strictObject({ requestId: AppPwaId, deviceId: AppPwaId, subject: z.unknown() }), await body(c));
|
|
199
|
+
return c.json(await service.issueVisit(signIn, input, c.req.raw.signal));
|
|
200
|
+
});
|
|
201
|
+
router.post(`${staffBase}/visits/revoke`, async (c) => {
|
|
202
|
+
const signIn = await staff(c);
|
|
203
|
+
const input = parse(z.strictObject({ deviceId: AppPwaId, visitId: AppPwaId }), await body(c));
|
|
204
|
+
return c.json(await service.revokeVisit(signIn, input.deviceId, input.visitId, c.req.raw.signal));
|
|
205
|
+
});
|
|
206
|
+
// Exposing only fetch prevents app authors from accidentally appending an unguarded generic guest route.
|
|
207
|
+
return {
|
|
208
|
+
guestApiPath: guestBase,
|
|
209
|
+
staffApiPath: staffBase,
|
|
210
|
+
fetch: async (request: Request): Promise<Response> => {
|
|
211
|
+
const signal = AbortSignal.any([request.signal, AbortSignal.timeout(REQUEST_IDLE_TIMEOUT_SECONDS * 1000)]);
|
|
212
|
+
try {
|
|
213
|
+
return await untilAppPwaAbort(signal, async () => router.fetch(new Request(request, { signal })));
|
|
214
|
+
} catch {
|
|
215
|
+
return Response.json(
|
|
216
|
+
{ code: "UNAVAILABLE" },
|
|
217
|
+
{
|
|
218
|
+
status: 503,
|
|
219
|
+
headers: {
|
|
220
|
+
"Cache-Control": "no-store, no-transform",
|
|
221
|
+
Pragma: "no-cache",
|
|
222
|
+
"Referrer-Policy": "no-referrer",
|
|
223
|
+
"X-Content-Type-Options": "nosniff",
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
export const createAppPwa = (options: AppPwaOptions) => {
|
|
233
|
+
const service = createAppPwaService(options);
|
|
234
|
+
return { service, router: createAppPwaRouter(service), setup: service.setup };
|
|
235
|
+
};
|
|
@@ -10,7 +10,7 @@ export const approvalMessages = i18n.define({
|
|
|
10
10
|
rejectAllTitle: "Reject all open approvals?",
|
|
11
11
|
rejectAllBody: ({ count }: { count: number }) =>
|
|
12
12
|
`${count === 1 ? "The open Action does not run." : `None of the ${count.toLocaleString("en")} open Actions runs.`} Where Cloud asked, it continues with your rejection.`,
|
|
13
|
-
emptyTitle: "
|
|
13
|
+
emptyTitle: "You're all set.",
|
|
14
14
|
emptyBody: "When an Action needs your approval, it appears here and in the chat it comes from.",
|
|
15
15
|
loading: "Loading approvals…",
|
|
16
16
|
loadFailed: "Approvals could not be loaded.",
|
|
@@ -48,7 +48,7 @@ export const approvalMessages = i18n.define({
|
|
|
48
48
|
rejectAllTitle: "Alle offenen Freigaben ablehnen?",
|
|
49
49
|
rejectAllBody: ({ count }) =>
|
|
50
50
|
`${count === 1 ? "Die offene Aktion läuft nicht." : `Keine der ${count.toLocaleString("de")} offenen Aktionen läuft.`} Wo Cloud gefragt hat, geht es mit deiner Ablehnung weiter.`,
|
|
51
|
-
emptyTitle: "
|
|
51
|
+
emptyTitle: "Alles erledigt.",
|
|
52
52
|
emptyBody: "Wenn eine Aktion deine Freigabe braucht, erscheint sie hier und im Chat, aus dem sie kommt.",
|
|
53
53
|
loading: "Freigaben werden geladen …",
|
|
54
54
|
loadFailed: "Die Freigaben konnten nicht geladen werden.",
|