akanjs 3.1.1 → 3.1.2-rc.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/README.ko.md +22 -0
- package/README.md +22 -0
- package/client/cookie.ts +23 -7
- package/client/csrTypes.ts +1 -1
- package/client/makePageProto.tsx +10 -1
- package/client/router.ts +57 -16
- package/common/IpLiteral.ts +4 -2
- package/common/TrustedProxy.ts +45 -2
- package/common/akanNames.ts +169 -0
- package/common/codeAgentProfile.ts +51 -4
- package/common/index.ts +13 -0
- package/common/localeConfig.ts +30 -5
- package/common/localePath.ts +26 -0
- package/common/markdownSpans.ts +3 -1
- package/common/mcpExposure.ts +7 -7
- package/common/websocketContract.ts +50 -0
- package/constant/index.ts +1 -0
- package/constant/inputParser.ts +10 -2
- package/constant/requestValue.ts +47 -0
- package/dictionary/agent.dictionary.ts +2 -2
- package/dictionary/base.dictionary.ts +4 -0
- package/dictionary/dictInfo.ts +9 -9
- package/document/database.ts +1 -0
- package/document/documentQuery.ts +42 -6
- package/document/filterMeta.ts +56 -8
- package/document/queryEvaluator.ts +13 -8
- package/fetch/client/fetchClient.ts +28 -62
- package/fetch/client/httpClient.ts +1 -0
- package/fetch/client/sliceInitHandle.ts +18 -18
- package/fetch/index.ts +1 -0
- package/fetch/sessionCookie.ts +37 -0
- package/index.ts +8 -0
- package/package.json +13 -27
- package/server/akanApp.ts +108 -29
- package/server/akanServer.ts +18 -7
- package/server/assetHeaders.ts +20 -1
- package/server/cachePolicy.ts +18 -5
- package/server/devtools/depsSerializer.ts +2 -1
- package/server/devtools/devtoolsRouter.ts +3 -2
- package/server/devtools/signalSerializer.ts +5 -5
- package/server/hmr/clientReport.ts +10 -5
- package/server/imageOptimizer.ts +117 -22
- package/server/lifecycle/buildEnv.ts +11 -0
- package/server/lifecycle/loadBuildEnv.ts +3 -0
- package/server/logging/logControlSocket.ts +20 -14
- package/server/logging/logStreamRoute.ts +12 -8
- package/server/logging/rotatingLogWriter.ts +15 -1
- package/server/machine/machineToken.ts +4 -0
- package/server/mcp/McpAuth.ts +12 -4
- package/server/mcp/McpExecutionContext.ts +4 -0
- package/server/mcp/McpRouter.ts +25 -13
- package/server/mcp/PagePromptComposer.ts +4 -7
- package/server/oauth/OAuthClientIdMetadata.ts +29 -6
- package/server/oauth/OAuthRegistration.ts +9 -0
- package/server/ops/failureLimiter.ts +42 -0
- package/server/ops/opsCommand.ts +19 -7
- package/server/ops/opsRoute.ts +60 -9
- package/server/ops/opsToken.ts +5 -0
- package/server/ops/snapshotJobs.ts +3 -1
- package/server/ops/snapshotRestore.ts +23 -1
- package/server/ops/sqliteSnapshot.ts +98 -18
- package/server/ops/statusAccess.ts +37 -19
- package/server/proxy/hostBasePathWebProxy.ts +4 -7
- package/server/proxy/localeWebProxy.ts +36 -16
- package/server/resolver/database.resolver.ts +15 -11
- package/server/resolver/service.resolver.ts +17 -2
- package/server/resolver/signal.resolver.ts +50 -40
- package/server/routeElementComposer.tsx +42 -9
- package/server/routeShellGate.ts +42 -0
- package/server/routing/apiRouter.ts +64 -1
- package/server/routing/serverLimits.ts +151 -0
- package/server/routing/soloAppRoutes.ts +23 -12
- package/server/rscClient.tsx +5 -3
- package/server/rscScrollRestoration.ts +60 -9
- package/server/rscWorker.tsx +76 -24
- package/server/sitemap.ts +5 -7
- package/server/ssrFromRscRenderer.tsx +36 -4
- package/server/ssrTypes.ts +5 -2
- package/server/staticPath.ts +36 -1
- package/server/systemPageDocument.tsx +5 -3
- package/server/systemPages.tsx +14 -8
- package/server/tunnel/TunnelAgent.ts +19 -2
- package/server/tunnel/TunnelDataSocket.ts +4 -2
- package/server/tunnel/TunnelHttpStream.ts +7 -2
- package/server/tunnel/TunnelTcpStream.ts +6 -3
- package/server/tunnel/TunnelWebsocketStream.ts +9 -2
- package/server/tunnel/tunnelStream.ts +6 -0
- package/server/types.tsx +20 -2
- package/server/webRouter.ts +38 -18
- package/service/agent.service.ts +9 -7
- package/service/injectInfo.ts +2 -1
- package/service/liveSyncHub.ts +6 -0
- package/service/predefinedAdaptor/anthropicLlm.ts +13 -12
- package/service/predefinedAdaptor/database.adaptor.ts +4 -0
- package/service/predefinedAdaptor/llm.adaptor.ts +3 -0
- package/service/predefinedAdaptor/llmOverflow.ts +5 -1
- package/service/predefinedAdaptor/openaiDialect.ts +9 -8
- package/service/predefinedAdaptor/searchIndex.ts +21 -1
- package/service/predefinedAdaptor/sql/QueryCompiler.ts +28 -4
- package/service/predefinedAdaptor/sql/SqlDocumentStore.ts +45 -11
- package/service/predefinedAdaptor/sql/dialect/postgres.ts +6 -1
- package/service/predefinedAdaptor/sql/types.ts +3 -0
- package/service/predefinedAdaptor/storage.adaptor.ts +11 -10
- package/service/serve.ts +3 -3
- package/service/serviceModule.ts +28 -24
- package/service/types.ts +3 -0
- package/signal/SignalFailure.ts +5 -2
- package/signal/agent/AgentCatalogue.ts +3 -2
- package/signal/agentMeter.ts +81 -9
- package/signal/agentRelayPolicy.ts +53 -2
- package/signal/agentTurnStream.ts +12 -1
- package/signal/guard.ts +2 -2
- package/signal/mcp/McpDocument.ts +3 -3
- package/signal/mcp/McpUriTemplate.ts +5 -5
- package/signal/middleware.ts +15 -1
- package/signal/roomKey.ts +53 -0
- package/signal/signalContext.ts +100 -40
- package/signal/slice.ts +27 -22
- package/signal/trace.ts +17 -6
- package/store/action.ts +20 -49
- package/store/actionTag.ts +2 -0
- package/store/agent/ScreenReader.ts +36 -4
- package/store/agent/ScreenTarget.ts +1 -1
- package/store/agent/StoreCatalogue.ts +7 -1
- package/store/agent/StoreSurfaceSource.ts +5 -0
- package/store/agentic/AgentConfirm.ts +30 -0
- package/store/agentic/StStateBuilder.ts +2 -2
- package/store/agentic/StToolBuilder.ts +7 -2
- package/store/agentic/formFields.ts +1 -2
- package/store/agentic/index.ts +1 -0
- package/store/agentic/useFieldTool.ts +4 -3
- package/store/deprecatedNames.ts +6 -0
- package/store/draftStore.ts +2 -2
- package/store/formUploadOf.ts +6 -5
- package/store/index.ts +1 -2
- package/store/sliceKeys.ts +9 -32
- package/store/state.ts +1 -1
- package/store/stateBuilder.ts +1 -1
- package/store/store.ts +1 -1
- package/store/storeInstance.ts +12 -8
- package/store/storeRegistry.ts +14 -2
- package/types/client/cookie.d.ts +7 -2
- package/types/client/csrTypes.d.ts +1 -1
- package/types/client/router.d.ts +4 -1
- package/types/common/akanNames.d.ts +149 -0
- package/types/common/codeAgentProfile.d.ts +4 -1
- package/types/common/index.d.ts +3 -1
- package/types/common/localeConfig.d.ts +8 -0
- package/types/common/localePath.d.ts +13 -0
- package/types/common/websocketContract.d.ts +7 -0
- package/types/constant/index.d.ts +1 -0
- package/types/constant/requestValue.d.ts +5 -0
- package/types/dictionary/base.dictionary.d.ts +1 -1
- package/types/dictionary/dictionary.d.ts +3 -3
- package/types/document/database.d.ts +1 -0
- package/types/document/documentQuery.d.ts +3 -0
- package/types/document/filterMeta.d.ts +4 -0
- package/types/fetch/client/sliceInitHandle.d.ts +1 -2
- package/types/fetch/index.d.ts +1 -0
- package/types/fetch/sessionCookie.d.ts +12 -0
- package/types/index.d.ts +8 -0
- package/types/server/akanApp.d.ts +1 -0
- package/types/server/assetHeaders.d.ts +5 -0
- package/types/server/cachePolicy.d.ts +2 -0
- package/types/server/devtools/devtoolsRouter.d.ts +1 -1
- package/types/server/imageOptimizer.d.ts +4 -1
- package/types/server/lifecycle/buildEnv.d.ts +2 -0
- package/types/server/lifecycle/loadBuildEnv.d.ts +1 -0
- package/types/server/logging/logStreamRoute.d.ts +1 -1
- package/types/server/logging/rotatingLogWriter.d.ts +3 -0
- package/types/server/machine/machineToken.d.ts +2 -0
- package/types/server/mcp/McpAuth.d.ts +2 -1
- package/types/server/mcp/McpExecutionContext.d.ts +1 -0
- package/types/server/oauth/OAuthRegistration.d.ts +2 -0
- package/types/server/ops/failureLimiter.d.ts +11 -0
- package/types/server/ops/opsRoute.d.ts +4 -2
- package/types/server/ops/opsToken.d.ts +2 -0
- package/types/server/ops/snapshotRestore.d.ts +3 -1
- package/types/server/ops/sqliteSnapshot.d.ts +7 -1
- package/types/server/ops/statusAccess.d.ts +8 -4
- package/types/server/proxy/hostBasePathWebProxy.d.ts +1 -0
- package/types/server/resolver/signal.resolver.d.ts +0 -1
- package/types/server/routeElementComposer.d.ts +10 -4
- package/types/server/routeShellGate.d.ts +12 -0
- package/types/server/routing/apiRouter.d.ts +6 -1
- package/types/server/routing/serverLimits.d.ts +23 -0
- package/types/server/routing/soloAppRoutes.d.ts +1 -1
- package/types/server/rscScrollRestoration.d.ts +1 -0
- package/types/server/ssrTypes.d.ts +5 -2
- package/types/server/staticPath.d.ts +1 -0
- package/types/server/systemPageDocument.d.ts +1 -0
- package/types/server/tunnel/TunnelAgent.d.ts +6 -0
- package/types/server/tunnel/TunnelDataSocket.d.ts +2 -0
- package/types/server/tunnel/TunnelTcpStream.d.ts +1 -1
- package/types/server/tunnel/tunnelStream.d.ts +1 -0
- package/types/server/types.d.ts +11 -2
- package/types/service/agent.service.d.ts +1 -1
- package/types/service/liveSyncHub.d.ts +1 -0
- package/types/service/predefinedAdaptor/anthropicLlm.d.ts +1 -1
- package/types/service/predefinedAdaptor/llm.adaptor.d.ts +1 -0
- package/types/service/predefinedAdaptor/openaiDialect.d.ts +1 -1
- package/types/service/predefinedAdaptor/searchIndex.d.ts +5 -1
- package/types/service/predefinedAdaptor/sql/SqlDocumentStore.d.ts +1 -0
- package/types/service/predefinedAdaptor/sql/types.d.ts +3 -0
- package/types/service/types.d.ts +3 -0
- package/types/signal/SignalFailure.d.ts +1 -1
- package/types/signal/agentMeter.d.ts +4 -1
- package/types/signal/agentRelayPolicy.d.ts +12 -0
- package/types/signal/guard.d.ts +1 -1
- package/types/signal/roomKey.d.ts +8 -0
- package/types/signal/signalContext.d.ts +20 -6
- package/types/signal/slice.d.ts +1 -0
- package/types/signal/trace.d.ts +4 -0
- package/types/store/actionTag.d.ts +2 -0
- package/types/store/agent/ScreenReader.d.ts +2 -1
- package/types/store/agent/StoreCatalogue.d.ts +5 -0
- package/types/store/agentic/AgentConfirm.d.ts +15 -0
- package/types/store/agentic/StToolBuilder.d.ts +3 -1
- package/types/store/agentic/index.d.ts +1 -0
- package/types/store/deprecatedNames.d.ts +5 -0
- package/types/store/index.d.ts +1 -2
- package/types/store/sliceKeys.d.ts +12 -2
- package/types/ui/Agent/agentSessionOf.d.ts +2 -0
- package/types/ui/Agent/useAccountBoundSession.d.ts +2 -0
- package/types/ui/Agent/useScreenActions.d.ts +1 -1
- package/types/ui/Icon.d.ts +15 -0
- package/types/ui/animated.d.ts +3 -3
- package/types/ui/sliceNamesOf.d.ts +4 -4
- package/types/vendor/@akanjs/native/packages/cli/src/config.d.ts +10 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/updates.d.ts +6 -0
- package/types/vendor/@akanjs/native/packages/core/src/kernel.d.ts +6 -1
- package/types/vendor/@akanjs/native/packages/desktop/src/paths.d.ts +7 -0
- package/types/vendor/@akanjs/native/plugins/single-instance/src/socket.d.ts +2 -0
- package/types/vendor/@akanjs/native/plugins/updates/src/desktop.d.ts +2 -0
- package/types/vendor/use-agentic/AgentSession.d.ts +6 -1
- package/types/vendor/use-agentic/ToolRunner.d.ts +7 -2
- package/types/vendor/use-agentic/types.d.ts +2 -0
- package/types/vendor/use-agentic/useAgentTool.d.ts +2 -1
- package/types/webkit/useCsrValues.d.ts +3 -3
- package/ui/Agent/Chat.tsx +2 -0
- package/ui/Agent/Zone.tsx +2 -0
- package/ui/Agent/agentSessionOf.ts +54 -5
- package/ui/Agent/useAccountBoundSession.ts +26 -0
- package/ui/Agent/useScreenActions.ts +1 -1
- package/ui/BottomSheet.tsx +1 -1
- package/ui/Button.tsx +5 -5
- package/ui/Data/Item.tsx +2 -8
- package/ui/Data/ListContainer.tsx +4 -4
- package/ui/Data/QueryMaker.tsx +3 -2
- package/ui/Data/RefPicker.tsx +3 -2
- package/ui/Data/UnlistedEditModal.tsx +2 -1
- package/ui/Data/ViewActions.tsx +4 -4
- package/ui/Data/dataExport.ts +3 -0
- package/ui/Data/useViewModal.ts +10 -15
- package/ui/Dialog/LegacyModal.tsx +1 -1
- package/ui/Dialog/Provider.tsx +2 -2
- package/ui/DragAction.tsx +1 -1
- package/ui/Dropdown.tsx +2 -2
- package/ui/Field/lightRefCache.ts +2 -2
- package/ui/Field/useRelationOptions.ts +3 -10
- package/ui/Icon.tsx +87 -0
- package/ui/KeyboardAvoiding.tsx +1 -1
- package/ui/Layout/Sider.tsx +3 -3
- package/ui/Link/Anchor.tsx +4 -1
- package/ui/Load/Edit.tsx +2 -2
- package/ui/Load/Edit_Client.tsx +3 -3
- package/ui/Load/Pagination.tsx +3 -3
- package/ui/Load/View.tsx +15 -14
- package/ui/Loading/ProgressBar.tsx +1 -1
- package/ui/Model/DraftBar.tsx +7 -14
- package/ui/Model/EditModal.tsx +9 -40
- package/ui/Model/EditWrapper.tsx +3 -5
- package/ui/Model/NewWrapper_Client.tsx +3 -7
- package/ui/Model/Remove.tsx +2 -2
- package/ui/Model/RemoveWrapper.tsx +2 -4
- package/ui/Model/SureToRemove.tsx +2 -2
- package/ui/Model/ViewEditModal.tsx +9 -20
- package/ui/Model/ViewModal.tsx +4 -11
- package/ui/Model/ViewWrapper.tsx +4 -6
- package/ui/ScreenNavigator.tsx +2 -2
- package/ui/Signal/WebSocket.tsx +2 -2
- package/ui/System/CSR.tsx +3 -3
- package/ui/System/Reconnect.tsx +2 -2
- package/ui/System/SSR.tsx +2 -2
- package/ui/System/SelectLanguage.tsx +1 -1
- package/ui/System/ThemeToggle.tsx +1 -1
- package/ui/Tab/Provider.tsx +1 -0
- package/ui/Toast.tsx +7 -15
- package/ui/index.ts +0 -1
- package/ui/sliceNamesOf.ts +25 -15
- package/vendor/@akanjs/native/native/android/src/com/akanjs/runtime/AkanNativeActivity.kt +2 -1
- package/vendor/@akanjs/native/native/android/src/com/akanjs/runtime/AkanNativeKernel.kt +10 -2
- package/vendor/@akanjs/native/native/android/src/com/akanjs/runtime/AkanNativeVectors.kt +4 -0
- package/vendor/@akanjs/native/native/desktop/src/acl.rs +166 -0
- package/vendor/@akanjs/native/native/desktop/src/lib.rs +25 -6
- package/vendor/@akanjs/native/native/desktop/src/navigation.rs +22 -10
- package/vendor/@akanjs/native/native/desktop/src/routes.rs +2 -2
- package/vendor/@akanjs/native/native/desktop/src/vectors.rs +28 -0
- package/vendor/@akanjs/native/native/ios/Sources/AkanNativeBridge.swift +1 -1
- package/vendor/@akanjs/native/native/ios/Sources/AkanNativeKernel.swift +10 -2
- package/vendor/@akanjs/native/native/ios/Sources/AkanNativeVectors.swift +4 -0
- package/vendor/@akanjs/native/native/ios/Sources/AkanNativeViewController.swift +1 -0
- package/vendor/@akanjs/native/packages/cli/src/config.ts +10 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/configkeys.ts +2 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/csp.ts +5 -3
- package/vendor/@akanjs/native/packages/cli/src/lib/prepare.ts +4 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/publish.ts +2 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/updates.ts +30 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/vectors.ts +1 -1
- package/vendor/@akanjs/native/packages/core/src/kernel.ts +18 -2
- package/vendor/@akanjs/native/packages/core/vectors/navigation.json +22 -1
- package/vendor/@akanjs/native/packages/core/vectors/routes.json +4 -0
- package/vendor/@akanjs/native/packages/desktop/src/main.ts +5 -1
- package/vendor/@akanjs/native/packages/desktop/src/paths.ts +24 -1
- package/vendor/@akanjs/native/plugins/external-view/src/desktop.ts +4 -5
- package/vendor/@akanjs/native/plugins/file-picker/src/desktop.ts +6 -8
- package/vendor/@akanjs/native/plugins/filesystem/src/desktop.ts +4 -2
- package/vendor/@akanjs/native/plugins/single-instance/src/socket.ts +22 -3
- package/vendor/@akanjs/native/plugins/updates/android/UpdatesPlugin.kt +6 -0
- package/vendor/@akanjs/native/plugins/updates/ios/UpdatesPlugin.swift +6 -0
- package/vendor/@akanjs/native/plugins/updates/src/desktop.ts +106 -18
- package/vendor/use-agentic/AgentSession.ts +7 -2
- package/vendor/use-agentic/ToolRunner.ts +44 -5
- package/vendor/use-agentic/types.ts +2 -0
- package/vendor/use-agentic/useAgentTool.ts +4 -2
- package/webkit/McpAppAgent.ts +2 -1
- package/webkit/bootCsr.tsx +1 -1
- package/webkit/useCsrValues.ts +5 -1
- package/webkit/usePageTool.tsx +1 -0
- package/store/databaseStateNames.ts +0 -24
- package/store/formSetterNames.ts +0 -18
- package/types/store/databaseStateNames.d.ts +0 -18
- package/types/store/formSetterNames.d.ts +0 -11
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { lookup } from "node:dns/promises";
|
|
2
2
|
import { TrustedProxy } from "akanjs/common";
|
|
3
3
|
import { OAuthRedirect } from "./OAuthRedirect";
|
|
4
|
+
import { OAuthRegistration } from "./OAuthRegistration";
|
|
4
5
|
import type { OAuthClientRecord, OAuthRedirectPolicy } from "./oauthTypes";
|
|
5
6
|
|
|
6
7
|
export interface OAuthClientIdMetadataResult {
|
|
@@ -64,8 +65,8 @@ export class OAuthClientIdMetadata {
|
|
|
64
65
|
}).catch(() => null);
|
|
65
66
|
if (!res?.ok) return null;
|
|
66
67
|
if (Number(res.headers.get("content-length")) > OAuthClientIdMetadata.maxBytes) return null;
|
|
67
|
-
const text = await res
|
|
68
|
-
if (!text
|
|
68
|
+
const text = await OAuthClientIdMetadata.#readCapped(res).catch(() => null);
|
|
69
|
+
if (!text) return null;
|
|
69
70
|
let json: unknown;
|
|
70
71
|
try {
|
|
71
72
|
json = JSON.parse(text);
|
|
@@ -85,19 +86,41 @@ export class OAuthClientIdMetadata {
|
|
|
85
86
|
if (!Array.isArray(redirectUris) || !redirectUris.length) return null;
|
|
86
87
|
if (!redirectUris.every((uri) => typeof uri === "string" && OAuthRedirect.isRegistrable(uri, policy))) return null;
|
|
87
88
|
if ((doc.token_endpoint_auth_method ?? "none") !== "none") return null;
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
|
|
90
|
+
const named = Array.isArray(doc.grant_types) ? (doc.grant_types as unknown[]) : [];
|
|
91
|
+
const grantTypes = named.length
|
|
92
|
+
? [
|
|
93
|
+
...new Set(
|
|
94
|
+
named.filter((g): g is string => typeof g === "string" && OAuthRegistration.grantTypes.includes(g)),
|
|
95
|
+
),
|
|
96
|
+
]
|
|
97
|
+
: ["authorization_code"];
|
|
91
98
|
return {
|
|
92
99
|
clientId,
|
|
93
100
|
clientName: doc.client_name.trim(),
|
|
94
101
|
redirectUris: redirectUris as string[],
|
|
95
|
-
grantTypes
|
|
102
|
+
grantTypes,
|
|
96
103
|
tokenEndpointAuthMethod: "none",
|
|
97
104
|
source: "metadataDocument",
|
|
98
105
|
};
|
|
99
106
|
}
|
|
100
107
|
|
|
108
|
+
static async #readCapped(res: Response): Promise<string | null> {
|
|
109
|
+
const reader = res.body?.getReader();
|
|
110
|
+
if (!reader) return null;
|
|
111
|
+
const chunks: Uint8Array[] = [];
|
|
112
|
+
let bytes = 0;
|
|
113
|
+
for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
|
|
114
|
+
bytes += chunk.value.byteLength;
|
|
115
|
+
if (bytes > OAuthClientIdMetadata.maxBytes) {
|
|
116
|
+
await reader.cancel().catch(() => undefined);
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
chunks.push(chunk.value);
|
|
120
|
+
}
|
|
121
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
122
|
+
}
|
|
123
|
+
|
|
101
124
|
static readonly #lookup: OAuthAddressLookup = async (hostname) =>
|
|
102
125
|
await Promise.race([
|
|
103
126
|
lookup(hostname, { all: true }),
|
|
@@ -8,6 +8,9 @@ export class OAuthRegistration {
|
|
|
8
8
|
static readonly authMethods: readonly OAuthClientAuthMethod[] = ["none", "client_secret_post", "client_secret_basic"];
|
|
9
9
|
static readonly grantTypes: readonly string[] = ["authorization_code", "refresh_token"];
|
|
10
10
|
static readonly maxNameLength = 200;
|
|
11
|
+
|
|
12
|
+
static maxRedirectUris = 10;
|
|
13
|
+
static maxRedirectUriLength = 2048;
|
|
11
14
|
|
|
12
15
|
static parse(body: unknown, policy: OAuthRedirectPolicy = {}): OAuthRegistrationResult {
|
|
13
16
|
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
@@ -16,6 +19,12 @@ export class OAuthRegistration {
|
|
|
16
19
|
const redirectUris = meta.redirect_uris;
|
|
17
20
|
if (!OAuthRegistration.#strings(redirectUris) || !redirectUris.length)
|
|
18
21
|
return OAuthRegistration.#fail("invalid_redirect_uri", "`redirect_uris` must be a non-empty array of strings.");
|
|
22
|
+
const { maxRedirectUris, maxRedirectUriLength } = OAuthRegistration;
|
|
23
|
+
if (redirectUris.length > maxRedirectUris || redirectUris.some((uri) => uri.length > maxRedirectUriLength))
|
|
24
|
+
return OAuthRegistration.#fail(
|
|
25
|
+
"invalid_redirect_uri",
|
|
26
|
+
`\`redirect_uris\` may hold ${maxRedirectUris} URIs of at most ${maxRedirectUriLength} characters each.`,
|
|
27
|
+
);
|
|
19
28
|
const rejected = redirectUris.find((uri) => !OAuthRedirect.isRegistrable(uri, policy));
|
|
20
29
|
if (rejected !== undefined)
|
|
21
30
|
return OAuthRegistration.#fail(
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export interface FailureLimiterOptions {
|
|
2
|
+
limit: number;
|
|
3
|
+
windowMs?: number;
|
|
4
|
+
maxClients?: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export class FailureLimiter {
|
|
8
|
+
readonly #limit: number;
|
|
9
|
+
readonly #windowMs: number;
|
|
10
|
+
readonly #maxClients: number;
|
|
11
|
+
readonly #failures = new Map<string, number[]>();
|
|
12
|
+
|
|
13
|
+
constructor({ limit, windowMs = 60_000, maxClients = 10_000 }: FailureLimiterOptions) {
|
|
14
|
+
this.#limit = limit;
|
|
15
|
+
this.#windowMs = windowMs;
|
|
16
|
+
this.#maxClients = maxClients;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
blocked(client: string | null | undefined, now = Date.now()): boolean {
|
|
20
|
+
if (!client) return false;
|
|
21
|
+
return this.#recent(client, now).length >= this.#limit;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
fail(client: string | null | undefined, now = Date.now()) {
|
|
25
|
+
if (!client) return;
|
|
26
|
+
const recent = this.#recent(client, now);
|
|
27
|
+
this.#failures.delete(client);
|
|
28
|
+
while (this.#failures.size >= this.#maxClients) {
|
|
29
|
+
const oldest = this.#failures.keys().next().value;
|
|
30
|
+
if (oldest === undefined) break;
|
|
31
|
+
this.#failures.delete(oldest);
|
|
32
|
+
}
|
|
33
|
+
this.#failures.set(client, [...recent, now].slice(-this.#limit));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
#recent(client: string, now: number) {
|
|
37
|
+
const recent = (this.#failures.get(client) ?? []).filter((at) => now - at < this.#windowMs);
|
|
38
|
+
if (recent.length) this.#failures.set(client, recent);
|
|
39
|
+
else this.#failures.delete(client);
|
|
40
|
+
return recent;
|
|
41
|
+
}
|
|
42
|
+
}
|
package/server/ops/opsCommand.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { parseArgs } from "node:util";
|
|
4
|
+
import { getEnv } from "../../base/baseEnv";
|
|
4
5
|
import type { AkanServer } from "../akanServer";
|
|
5
6
|
import { MigrationCommand } from "../migration/migrationCommand";
|
|
6
7
|
import type { Migrations } from "../migration/migrations";
|
|
@@ -17,8 +18,8 @@ export interface OpsHost {
|
|
|
17
18
|
export class OpsCommand {
|
|
18
19
|
static readonly usage = [
|
|
19
20
|
"Usage:",
|
|
20
|
-
" bun main.js ops snapshot [--id <id>] [--out <dir>] [--include-solid] [--db <file>] [--solid-db <file>] [--json]",
|
|
21
|
-
" bun main.js ops restore <manifest.json> [--identity <file>] [--db <file>] [--solid-db <file>] [--force]",
|
|
21
|
+
" bun main.js ops snapshot [--id <id>] [--out <dir>] [--include-solid] [--db <file>] [--solid-db <file>] [--allow-plaintext] [--json]",
|
|
22
|
+
" bun main.js ops restore <manifest.json> [--identity <file>] [--db <file>] [--solid-db <file>] [--allow-plaintext] [--force]",
|
|
22
23
|
" bun main.js ops migrate [list|plan|apply|verify|rollback] [<id>] [--param <name>=<value>]... [--expect <planHash>] [--items <n>] [--actor <id>] [--json]",
|
|
23
24
|
].join("\n");
|
|
24
25
|
|
|
@@ -45,6 +46,7 @@ export class OpsCommand {
|
|
|
45
46
|
db: { type: "string" },
|
|
46
47
|
"solid-db": { type: "string" },
|
|
47
48
|
"include-solid": { type: "boolean", default: false },
|
|
49
|
+
"allow-plaintext": { type: "boolean", default: false },
|
|
48
50
|
json: { type: "boolean", default: false },
|
|
49
51
|
},
|
|
50
52
|
});
|
|
@@ -54,7 +56,7 @@ export class OpsCommand {
|
|
|
54
56
|
dir: path.resolve(values.out ?? SqliteFiles.snapshotDir(sources)),
|
|
55
57
|
sources,
|
|
56
58
|
includeSolid: values["include-solid"],
|
|
57
|
-
encryptor: await OpsCommand.#encryptor(),
|
|
59
|
+
encryptor: await OpsCommand.#encryptor(values["allow-plaintext"]),
|
|
58
60
|
});
|
|
59
61
|
process.stdout.write(values.json ? `${JSON.stringify(capture)}\n` : `${JSON.stringify(capture, null, 2)}\n`);
|
|
60
62
|
return 0;
|
|
@@ -68,6 +70,7 @@ export class OpsCommand {
|
|
|
68
70
|
identity: { type: "string" },
|
|
69
71
|
db: { type: "string" },
|
|
70
72
|
"solid-db": { type: "string" },
|
|
73
|
+
"allow-plaintext": { type: "boolean", default: false },
|
|
71
74
|
force: { type: "boolean", default: false },
|
|
72
75
|
},
|
|
73
76
|
});
|
|
@@ -82,6 +85,7 @@ export class OpsCommand {
|
|
|
82
85
|
manifestPath: path.resolve(manifestPath),
|
|
83
86
|
sources: OpsCommand.#sources(values.db, values["solid-db"]),
|
|
84
87
|
decryptor: await OpsCommand.#decryptor(values.identity),
|
|
88
|
+
allowPlaintext: values["allow-plaintext"],
|
|
85
89
|
});
|
|
86
90
|
for (const { role, target, preservedAs } of restored)
|
|
87
91
|
process.stdout.write(`restored ${role} → ${target}${preservedAs ? ` (previous kept at ${preservedAs})` : ""}\n`);
|
|
@@ -129,10 +133,18 @@ export class OpsCommand {
|
|
|
129
133
|
}
|
|
130
134
|
}
|
|
131
135
|
|
|
132
|
-
static async #encryptor() {
|
|
133
|
-
if (
|
|
134
|
-
|
|
135
|
-
|
|
136
|
+
static async #encryptor(allowPlaintext: boolean) {
|
|
137
|
+
if (process.env.AKAN_BACKUP_RECIPIENT?.trim()) {
|
|
138
|
+
const { AgeEncryption } = await import("./ageEncryption");
|
|
139
|
+
return await AgeEncryption.fromEnv();
|
|
140
|
+
}
|
|
141
|
+
if (getEnv().operationMode === "local") return null;
|
|
142
|
+
if (!allowPlaintext && !["1", "true"].includes(process.env.AKAN_BACKUP_PLAINTEXT ?? ""))
|
|
143
|
+
throw new Error(
|
|
144
|
+
"AKAN_BACKUP_RECIPIENT is unset; set it to an age recipient, or allow an unencrypted snapshot with --allow-plaintext or AKAN_BACKUP_PLAINTEXT=1",
|
|
145
|
+
);
|
|
146
|
+
process.stderr.write("ops snapshot: AKAN_BACKUP_RECIPIENT is unset, so this snapshot is not encrypted\n");
|
|
147
|
+
return null;
|
|
136
148
|
}
|
|
137
149
|
|
|
138
150
|
static async #decryptor(identityFile = process.env.AKAN_BACKUP_IDENTITY_FILE) {
|
package/server/ops/opsRoute.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { Logger } from "akanjs/common";
|
|
2
2
|
import { getEnv } from "../../base/baseEnv";
|
|
3
|
+
import { Ed25519Key } from "../ed25519Key";
|
|
4
|
+
import { MachineToken } from "../machine/machineToken";
|
|
3
5
|
import { SelfExec } from "../selfExec";
|
|
4
6
|
import type { AppDetailInfo } from "./appInfo";
|
|
7
|
+
import { FailureLimiter } from "./failureLimiter";
|
|
5
8
|
import { MigrateJobs, type MigrateProcessOutput } from "./migrateJobs";
|
|
6
9
|
import { type OpsTokenClaims, OpsTokenVerifier } from "./opsToken";
|
|
7
10
|
import { type SnapshotJobRequest, SnapshotJobs } from "./snapshotJobs";
|
|
@@ -15,6 +18,7 @@ export interface OpsRouteOptions {
|
|
|
15
18
|
jobs: SnapshotJobs;
|
|
16
19
|
migrateJobs: MigrateJobs;
|
|
17
20
|
rateLimitPerMinute?: number;
|
|
21
|
+
failuresPerMinute?: number;
|
|
18
22
|
}
|
|
19
23
|
|
|
20
24
|
export interface OpsRouteEnvOptions {
|
|
@@ -24,6 +28,7 @@ export interface OpsRouteEnvOptions {
|
|
|
24
28
|
|
|
25
29
|
export class OpsRoute {
|
|
26
30
|
static readonly prefix = "/_akan/ops/";
|
|
31
|
+
static readonly loggedRefusalsPerMinute = 10;
|
|
27
32
|
|
|
28
33
|
readonly #logger = new Logger("OpsRoute");
|
|
29
34
|
readonly #verifier: OpsTokenVerifier;
|
|
@@ -31,14 +36,25 @@ export class OpsRoute {
|
|
|
31
36
|
readonly #jobs: SnapshotJobs;
|
|
32
37
|
readonly #migrateJobs: MigrateJobs;
|
|
33
38
|
readonly #rateLimit: number;
|
|
39
|
+
readonly #failures: FailureLimiter;
|
|
34
40
|
#window: number[] = [];
|
|
41
|
+
#refusalWindowAt = 0;
|
|
42
|
+
#refusalCount = 0;
|
|
35
43
|
|
|
36
|
-
constructor({
|
|
44
|
+
constructor({
|
|
45
|
+
verifier,
|
|
46
|
+
detail,
|
|
47
|
+
jobs,
|
|
48
|
+
migrateJobs,
|
|
49
|
+
rateLimitPerMinute = 30,
|
|
50
|
+
failuresPerMinute = 10,
|
|
51
|
+
}: OpsRouteOptions) {
|
|
37
52
|
this.#verifier = verifier;
|
|
38
53
|
this.#detail = detail;
|
|
39
54
|
this.#jobs = jobs;
|
|
40
55
|
this.#migrateJobs = migrateJobs;
|
|
41
56
|
this.#rateLimit = rateLimitPerMinute;
|
|
57
|
+
this.#failures = new FailureLimiter({ limit: failuresPerMinute });
|
|
42
58
|
}
|
|
43
59
|
|
|
44
60
|
static fromEnv({ detail, sources = SqliteFiles.fromEnv }: OpsRouteEnvOptions): OpsRoute | null {
|
|
@@ -48,21 +64,28 @@ export class OpsRoute {
|
|
|
48
64
|
const logger = new Logger("OpsRoute");
|
|
49
65
|
let verifier: OpsTokenVerifier;
|
|
50
66
|
try {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
)
|
|
67
|
+
const key = OpsTokenVerifier.parsePublicKey(publicKey);
|
|
68
|
+
|
|
69
|
+
const machineKey = OpsRoute.#machinePublicKey();
|
|
70
|
+
if (machineKey && Ed25519Key.rawPublic(machineKey) === Ed25519Key.rawPublic(key))
|
|
71
|
+
throw new Error("AKAN_OPS_PUBLIC_KEY is also the machine token key; give the ops channel a key of its own");
|
|
72
|
+
verifier = new OpsTokenVerifier(key, OpsTokenVerifier.audienceOf(appName, environment));
|
|
55
73
|
} catch (error) {
|
|
56
74
|
|
|
57
75
|
logger.error(`Ops channel disabled: ${error instanceof Error ? error.message : String(error)}`);
|
|
58
76
|
return null;
|
|
59
77
|
}
|
|
60
78
|
const snapshotDir = SqliteFiles.snapshotDir(sources());
|
|
79
|
+
void SqliteSnapshot.sweep(snapshotDir);
|
|
61
80
|
const jobs = new SnapshotJobs({
|
|
62
81
|
snapshotDir,
|
|
63
82
|
capture: (request) => OpsRoute.captureInChild(request, sources(), snapshotDir),
|
|
64
83
|
});
|
|
65
|
-
logger.info(
|
|
84
|
+
logger.info(
|
|
85
|
+
process.env.AKAN_OPS_INSTANCE?.trim()
|
|
86
|
+
? `Ops channel enabled for audience ${verifier.audience}`
|
|
87
|
+
: `Ops channel enabled for audience ${verifier.audience}; without AKAN_OPS_INSTANCE a token for it is good on every instance of ${appName}/${environment}`,
|
|
88
|
+
);
|
|
66
89
|
const migrateJobs = new MigrateJobs((argv) => OpsRoute.migrateInChild(argv));
|
|
67
90
|
return new OpsRoute({ verifier, detail, jobs, migrateJobs });
|
|
68
91
|
}
|
|
@@ -104,14 +127,17 @@ export class OpsRoute {
|
|
|
104
127
|
return pathname.startsWith(OpsRoute.prefix);
|
|
105
128
|
}
|
|
106
129
|
|
|
107
|
-
async handle(req: Request): Promise<Response> {
|
|
130
|
+
async handle(req: Request, client?: string | null): Promise<Response> {
|
|
108
131
|
const url = new URL(req.url);
|
|
109
|
-
if (
|
|
132
|
+
if (this.#failures.blocked(client)) return OpsRoute.#rateLimited();
|
|
110
133
|
const verdict = this.#verifier.verifyRequest(req);
|
|
111
134
|
if (!verdict.ok) {
|
|
112
|
-
this.#
|
|
135
|
+
this.#failures.fail(client);
|
|
136
|
+
this.#noteRefusal(url.pathname, verdict.reason);
|
|
113
137
|
return OpsRoute.#json({ error: "unauthorized" }, 401, { "www-authenticate": "Bearer" });
|
|
114
138
|
}
|
|
139
|
+
|
|
140
|
+
if (!this.#admit()) return OpsRoute.#rateLimited();
|
|
115
141
|
const route = url.pathname.slice(OpsRoute.prefix.length);
|
|
116
142
|
if (route === "info" && req.method === "GET") return OpsRoute.#json(this.#detail(), 200);
|
|
117
143
|
if (route === "snapshot" && req.method === "POST") return await this.#startSnapshot(req);
|
|
@@ -178,6 +204,31 @@ export class OpsRoute {
|
|
|
178
204
|
return true;
|
|
179
205
|
}
|
|
180
206
|
|
|
207
|
+
#noteRefusal(pathname: string, reason: string) {
|
|
208
|
+
const now = Date.now();
|
|
209
|
+
if (now - this.#refusalWindowAt >= 60_000) {
|
|
210
|
+
this.#refusalWindowAt = now;
|
|
211
|
+
this.#refusalCount = 0;
|
|
212
|
+
}
|
|
213
|
+
this.#refusalCount += 1;
|
|
214
|
+
if (this.#refusalCount <= OpsRoute.loggedRefusalsPerMinute)
|
|
215
|
+
this.#logger.warn(`Refused ops call to ${pathname}: ${reason}`);
|
|
216
|
+
else if (this.#refusalCount === OpsRoute.loggedRefusalsPerMinute + 1)
|
|
217
|
+
this.#logger.warn("Refusing further ops calls this minute without logging each");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
static #machinePublicKey() {
|
|
221
|
+
try {
|
|
222
|
+
return MachineToken.publicKey();
|
|
223
|
+
} catch {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
static #rateLimited() {
|
|
229
|
+
return OpsRoute.#json({ error: "rate limited" }, 429, { "retry-after": "60" });
|
|
230
|
+
}
|
|
231
|
+
|
|
181
232
|
static #json(value: unknown, status: number, headers: Record<string, string> = {}) {
|
|
182
233
|
return Response.json(value, { status, headers: { "cache-control": "no-store", ...headers } });
|
|
183
234
|
}
|
package/server/ops/opsToken.ts
CHANGED
|
@@ -15,6 +15,9 @@ export interface OpsTokenClaims {
|
|
|
15
15
|
export type OpsTokenVerdict = { ok: true; claims: OpsTokenClaims } | { ok: false; reason: string };
|
|
16
16
|
|
|
17
17
|
export class OpsTokenVerifier {
|
|
18
|
+
static readonly type = "akan-ops+jwt";
|
|
19
|
+
|
|
20
|
+
static readonly acceptedTypes: ReadonlySet<unknown> = new Set([undefined, "JWT", OpsTokenVerifier.type]);
|
|
18
21
|
static readonly maxLifetimeSec = 300;
|
|
19
22
|
static readonly clockSkewSec = 30;
|
|
20
23
|
static readonly maxRememberedJti = 10_000;
|
|
@@ -45,6 +48,8 @@ export class OpsTokenVerifier {
|
|
|
45
48
|
}
|
|
46
49
|
|
|
47
50
|
verify(token: string, nowSec = Math.floor(Date.now() / 1000)): OpsTokenVerdict {
|
|
51
|
+
if (!OpsTokenVerifier.acceptedTypes.has(EdDsaJwt.headerOf(token)?.typ))
|
|
52
|
+
return { ok: false, reason: `typ must be ${OpsTokenVerifier.type}` };
|
|
48
53
|
const verdict = EdDsaJwt.verify(token, this.#key, { audience: this.audience });
|
|
49
54
|
if (!verdict.ok) return verdict;
|
|
50
55
|
const { exp, iat, jti, sub } = verdict.claims;
|
|
@@ -60,6 +60,8 @@ export class SnapshotJobs {
|
|
|
60
60
|
async #run(job: SnapshotJobState, request: SnapshotJobRequest) {
|
|
61
61
|
try {
|
|
62
62
|
const capture = await this.#options.capture(request);
|
|
63
|
+
if (!capture.manifest.encryption)
|
|
64
|
+
this.#logger.warn(`Snapshot ${job.id} is not encrypted: AKAN_BACKUP_RECIPIENT is unset`);
|
|
63
65
|
job.manifest = capture.manifest;
|
|
64
66
|
job.status = "uploading";
|
|
65
67
|
for (const file of capture.manifest.files) {
|
|
@@ -118,7 +120,7 @@ export class SnapshotJobs {
|
|
|
118
120
|
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
119
121
|
const dated = await Promise.all(
|
|
120
122
|
entries
|
|
121
|
-
.filter((entry) => entry.isDirectory())
|
|
123
|
+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
|
|
122
124
|
.map(async (entry) => {
|
|
123
125
|
const full = path.join(dir, entry.name);
|
|
124
126
|
const manifest = await stat(path.join(full, "manifest.json")).catch(() => null);
|
|
@@ -16,6 +16,8 @@ export interface SnapshotRestoreOptions {
|
|
|
16
16
|
manifestPath: string;
|
|
17
17
|
sources: SnapshotSources;
|
|
18
18
|
decryptor?: SnapshotDecryptor | null;
|
|
19
|
+
/** Restores a manifest that records no encryption even though a decryptor was given. */
|
|
20
|
+
allowPlaintext?: boolean;
|
|
19
21
|
now?: Date;
|
|
20
22
|
}
|
|
21
23
|
|
|
@@ -33,9 +35,21 @@ interface StagedFile {
|
|
|
33
35
|
|
|
34
36
|
/** Offline only: it swaps files under a closed database, and nothing here can tell an open connection to let go. */
|
|
35
37
|
export class SnapshotRestore {
|
|
36
|
-
static async run({
|
|
38
|
+
static async run({
|
|
39
|
+
manifestPath,
|
|
40
|
+
sources,
|
|
41
|
+
decryptor = null,
|
|
42
|
+
allowPlaintext = false,
|
|
43
|
+
now = new Date(),
|
|
44
|
+
}: SnapshotRestoreOptions) {
|
|
37
45
|
const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as SnapshotManifest;
|
|
38
46
|
if (manifest.format !== "akan-snapshot/v1") throw new Error(`Unknown snapshot format "${String(manifest.format)}"`);
|
|
47
|
+
|
|
48
|
+
if (decryptor && !manifest.encryption && !allowPlaintext)
|
|
49
|
+
throw new Error(
|
|
50
|
+
"The manifest records no encryption although an identity was given; pass --allow-plaintext to restore it",
|
|
51
|
+
);
|
|
52
|
+
for (const file of manifest.files) SnapshotRestore.#assertFile(file);
|
|
39
53
|
const dir = path.dirname(manifestPath);
|
|
40
54
|
const stamp = now.toISOString().replace(/[:.]/g, "-");
|
|
41
55
|
const staged: StagedFile[] = [];
|
|
@@ -100,6 +114,14 @@ export class SnapshotRestore {
|
|
|
100
114
|
return stagedPath;
|
|
101
115
|
}
|
|
102
116
|
|
|
117
|
+
static #assertFile(file: SnapshotFile) {
|
|
118
|
+
if (file.role !== "main" && file.role !== "solid")
|
|
119
|
+
throw new Error(`Unknown snapshot file role "${String(file.role)}"`);
|
|
120
|
+
const name = String(file.name);
|
|
121
|
+
if (!name || name === "." || name === ".." || path.basename(name) !== name)
|
|
122
|
+
throw new Error(`Snapshot file "${name}" must name a file beside the manifest`);
|
|
123
|
+
}
|
|
124
|
+
|
|
103
125
|
static async #preserve(target: string, stamp: string) {
|
|
104
126
|
if (!existsSync(target)) return null;
|
|
105
127
|
const preserved = `${target}.pre-restore-${stamp}`;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { createReadStream, createWriteStream, existsSync } from "node:fs";
|
|
4
|
-
import { mkdir, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { chmod, mkdir, mkdtemp, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import { hostname } from "node:os";
|
|
5
6
|
import path from "node:path";
|
|
6
7
|
import { Transform } from "node:stream";
|
|
7
8
|
import { pipeline } from "node:stream/promises";
|
|
@@ -28,6 +29,7 @@ export interface SnapshotCaptureOptions {
|
|
|
28
29
|
sources: SnapshotSources;
|
|
29
30
|
includeSolid?: boolean;
|
|
30
31
|
encryptor?: SnapshotEncryptor | null;
|
|
32
|
+
fileMode?: number;
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
export interface SnapshotCapture {
|
|
@@ -40,6 +42,23 @@ export interface SnapshotCapture {
|
|
|
40
42
|
export class SqliteSnapshot {
|
|
41
43
|
static readonly manifestName = "manifest.json";
|
|
42
44
|
static readonly idPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
45
|
+
static readonly stagingPrefix = ".capture-";
|
|
46
|
+
static staleAfterMs = 60 * 60 * 1000;
|
|
47
|
+
static readonly #stagingName = /^\.capture-([0-9a-f]{8})\.(\d+)\.([0-9a-z]+)-/;
|
|
48
|
+
|
|
49
|
+
static fileMode(value = process.env.AKAN_OPS_SNAPSHOT_FILE_MODE) {
|
|
50
|
+
const raw = value?.trim();
|
|
51
|
+
if (!raw) return 0o600;
|
|
52
|
+
const mode = /^[0-7]{3,4}$/.test(raw) ? Number.parseInt(raw, 8) : 0;
|
|
53
|
+
if (mode > 0 && mode <= 0o777) return mode;
|
|
54
|
+
process.stderr.write(`AKAN_OPS_SNAPSHOT_FILE_MODE="${raw}" is not an octal mode such as 640; using 600\n`);
|
|
55
|
+
return 0o600;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
static stagingOwner(host = hostname(), pid = process.pid, startedAt = performance.timeOrigin) {
|
|
59
|
+
const hostKey = createHash("sha256").update(host).digest("hex").slice(0, 8);
|
|
60
|
+
return `${SqliteSnapshot.stagingPrefix}${hostKey}.${pid}.${Math.floor(startedAt).toString(36)}`;
|
|
61
|
+
}
|
|
43
62
|
|
|
44
63
|
static async capture({
|
|
45
64
|
id,
|
|
@@ -47,6 +66,7 @@ export class SqliteSnapshot {
|
|
|
47
66
|
sources,
|
|
48
67
|
includeSolid = false,
|
|
49
68
|
encryptor = null,
|
|
69
|
+
fileMode = SqliteSnapshot.fileMode(),
|
|
50
70
|
}: SnapshotCaptureOptions): Promise<SnapshotCapture> {
|
|
51
71
|
if (!SqliteSnapshot.idPattern.test(id)) throw new Error(`Invalid snapshot id "${id}"`);
|
|
52
72
|
const { appName, environment, databaseMode } = getEnv();
|
|
@@ -54,7 +74,9 @@ export class SqliteSnapshot {
|
|
|
54
74
|
throw new Error(`Snapshots support SQLite database modes only, not "${databaseMode ?? "unknown"}"`);
|
|
55
75
|
const targetDir = path.join(dir, id);
|
|
56
76
|
if (existsSync(targetDir)) throw new Error(`Snapshot ${id} already exists at ${targetDir}`);
|
|
57
|
-
await mkdir(
|
|
77
|
+
await mkdir(dir, { recursive: true });
|
|
78
|
+
await SqliteSnapshot.sweep(dir);
|
|
79
|
+
const staging = await mkdtemp(path.join(dir, `${SqliteSnapshot.stagingOwner()}-`));
|
|
58
80
|
try {
|
|
59
81
|
const roles: [SnapshotFileRole, string][] = [["main", sources.main]];
|
|
60
82
|
if (includeSolid && sources.solid) roles.push(["solid", sources.solid]);
|
|
@@ -62,9 +84,9 @@ export class SqliteSnapshot {
|
|
|
62
84
|
const integrity: string[] = [];
|
|
63
85
|
const paths: SnapshotCapture["paths"] = {};
|
|
64
86
|
for (const [role, source] of roles) {
|
|
65
|
-
const { file, problems
|
|
87
|
+
const { file, problems } = await SqliteSnapshot.#captureOne(role, source, staging, encryptor, fileMode);
|
|
66
88
|
files.push(file);
|
|
67
|
-
paths[role] =
|
|
89
|
+
paths[role] = path.join(targetDir, file.name);
|
|
68
90
|
integrity.push(...problems.map((problem) => `${role}: ${problem}`));
|
|
69
91
|
}
|
|
70
92
|
const { akanVersion, buildId } = AppInfo.build();
|
|
@@ -81,31 +103,89 @@ export class SqliteSnapshot {
|
|
|
81
103
|
integrity: integrity.length ? integrity : "ok",
|
|
82
104
|
encryption: encryptor?.encryption ?? null,
|
|
83
105
|
};
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
106
|
+
await writeFile(path.join(staging, SqliteSnapshot.manifestName), `${JSON.stringify(manifest, null, 2)}\n`, {
|
|
107
|
+
mode: fileMode,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await chmod(staging, fileMode | ((fileMode & 0o444) >> 2));
|
|
111
|
+
await rename(staging, targetDir);
|
|
112
|
+
return { dir: targetDir, manifest, manifestPath: path.join(targetDir, SqliteSnapshot.manifestName), paths };
|
|
87
113
|
} catch (error) {
|
|
88
|
-
await rm(
|
|
114
|
+
await rm(staging, { recursive: true, force: true });
|
|
89
115
|
throw error;
|
|
90
116
|
}
|
|
91
117
|
}
|
|
92
118
|
|
|
93
|
-
static async
|
|
119
|
+
static async sweep(dir: string) {
|
|
120
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
121
|
+
const staged = entries.filter(
|
|
122
|
+
(entry) => entry.isDirectory() && entry.name.startsWith(SqliteSnapshot.stagingPrefix),
|
|
123
|
+
);
|
|
124
|
+
await Promise.all(
|
|
125
|
+
staged.map(async (entry) => {
|
|
126
|
+
const staging = path.join(dir, entry.name);
|
|
127
|
+
if (await SqliteSnapshot.#abandoned(entry.name, staging))
|
|
128
|
+
await rm(staging, { recursive: true, force: true }).catch(() => undefined);
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
static async #abandoned(name: string, staging: string) {
|
|
134
|
+
const [, hostKey, pid, startedAt] = SqliteSnapshot.#stagingName.exec(name) ?? [];
|
|
135
|
+
const [, ownHost, ownPid, ownStart] = SqliteSnapshot.#stagingName.exec(`${SqliteSnapshot.stagingOwner()}-`) ?? [];
|
|
136
|
+
if (hostKey && hostKey === ownHost) {
|
|
137
|
+
if (pid === ownPid) return startedAt !== ownStart;
|
|
138
|
+
if (!SqliteSnapshot.#alive(Number(pid))) return true;
|
|
139
|
+
}
|
|
140
|
+
return Date.now() - (await SqliteSnapshot.#lastWrite(staging)) > SqliteSnapshot.staleAfterMs;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
static async #lastWrite(staging: string) {
|
|
144
|
+
const names = await readdir(staging).catch(() => []);
|
|
145
|
+
const files = [staging, ...names.map((name) => path.join(staging, name))];
|
|
146
|
+
const stats = await Promise.all(files.map((file) => stat(file).catch(() => null)));
|
|
147
|
+
return Math.max(0, ...stats.map((stats) => stats?.mtimeMs ?? 0));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
static #alive(pid: number) {
|
|
151
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
152
|
+
try {
|
|
153
|
+
process.kill(pid, 0);
|
|
154
|
+
return true;
|
|
155
|
+
} catch (error) {
|
|
156
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
static async #captureOne(
|
|
161
|
+
role: SnapshotFileRole,
|
|
162
|
+
source: string,
|
|
163
|
+
dir: string,
|
|
164
|
+
encryptor: SnapshotEncryptor | null,
|
|
165
|
+
fileMode: number,
|
|
166
|
+
) {
|
|
94
167
|
if (!existsSync(source)) throw new Error(`No ${role} database at ${source}`);
|
|
95
168
|
const baseName = path.basename(source);
|
|
96
169
|
const rawPath = path.join(dir, baseName);
|
|
97
|
-
SqliteSnapshot.vacuumInto(source, rawPath);
|
|
98
|
-
const problems = SqliteSnapshot.integrityProblems(rawPath);
|
|
99
|
-
const sizeBytes = (await stat(rawPath)).size;
|
|
100
170
|
const name = `${baseName}.gz${encryptor?.extension ?? ""}`;
|
|
101
|
-
const packedPath = path.join(dir, name);
|
|
102
171
|
const sourceHash = createHash("sha256");
|
|
103
172
|
const uploadHash = createHash("sha256");
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
173
|
+
let problems: string[];
|
|
174
|
+
let sizeBytes: number;
|
|
175
|
+
try {
|
|
176
|
+
SqliteSnapshot.vacuumInto(source, rawPath);
|
|
177
|
+
await chmod(rawPath, 0o600);
|
|
178
|
+
problems = SqliteSnapshot.integrityProblems(rawPath);
|
|
179
|
+
sizeBytes = (await stat(rawPath)).size;
|
|
180
|
+
const stages: NodeJS.ReadWriteStream[] = [SqliteSnapshot.tap(sourceHash), createGzip()];
|
|
181
|
+
if (encryptor) stages.push(encryptor.transform());
|
|
182
|
+
stages.push(SqliteSnapshot.tap(uploadHash));
|
|
183
|
+
const packed = createWriteStream(path.join(dir, name), { mode: fileMode });
|
|
184
|
+
await pipeline([createReadStream(rawPath), ...stages, packed]);
|
|
185
|
+
} finally {
|
|
186
|
+
await rm(rawPath, { force: true });
|
|
187
|
+
}
|
|
188
|
+
const packedPath = path.join(dir, name);
|
|
109
189
|
const file: SnapshotFile = {
|
|
110
190
|
role,
|
|
111
191
|
name,
|