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.
Files changed (333) hide show
  1. package/README.ko.md +22 -0
  2. package/README.md +22 -0
  3. package/client/cookie.ts +23 -7
  4. package/client/csrTypes.ts +1 -1
  5. package/client/makePageProto.tsx +10 -1
  6. package/client/router.ts +57 -16
  7. package/common/IpLiteral.ts +4 -2
  8. package/common/TrustedProxy.ts +45 -2
  9. package/common/akanNames.ts +169 -0
  10. package/common/codeAgentProfile.ts +51 -4
  11. package/common/index.ts +13 -0
  12. package/common/localeConfig.ts +30 -5
  13. package/common/localePath.ts +26 -0
  14. package/common/markdownSpans.ts +3 -1
  15. package/common/mcpExposure.ts +7 -7
  16. package/common/websocketContract.ts +50 -0
  17. package/constant/index.ts +1 -0
  18. package/constant/inputParser.ts +10 -2
  19. package/constant/requestValue.ts +47 -0
  20. package/dictionary/agent.dictionary.ts +2 -2
  21. package/dictionary/base.dictionary.ts +4 -0
  22. package/dictionary/dictInfo.ts +9 -9
  23. package/document/database.ts +1 -0
  24. package/document/documentQuery.ts +42 -6
  25. package/document/filterMeta.ts +56 -8
  26. package/document/queryEvaluator.ts +13 -8
  27. package/fetch/client/fetchClient.ts +28 -62
  28. package/fetch/client/httpClient.ts +1 -0
  29. package/fetch/client/sliceInitHandle.ts +18 -18
  30. package/fetch/index.ts +1 -0
  31. package/fetch/sessionCookie.ts +37 -0
  32. package/index.ts +8 -0
  33. package/package.json +13 -27
  34. package/server/akanApp.ts +108 -29
  35. package/server/akanServer.ts +18 -7
  36. package/server/assetHeaders.ts +20 -1
  37. package/server/cachePolicy.ts +18 -5
  38. package/server/devtools/depsSerializer.ts +2 -1
  39. package/server/devtools/devtoolsRouter.ts +3 -2
  40. package/server/devtools/signalSerializer.ts +5 -5
  41. package/server/hmr/clientReport.ts +10 -5
  42. package/server/imageOptimizer.ts +117 -22
  43. package/server/lifecycle/buildEnv.ts +11 -0
  44. package/server/lifecycle/loadBuildEnv.ts +3 -0
  45. package/server/logging/logControlSocket.ts +20 -14
  46. package/server/logging/logStreamRoute.ts +12 -8
  47. package/server/logging/rotatingLogWriter.ts +15 -1
  48. package/server/machine/machineToken.ts +4 -0
  49. package/server/mcp/McpAuth.ts +12 -4
  50. package/server/mcp/McpExecutionContext.ts +4 -0
  51. package/server/mcp/McpRouter.ts +25 -13
  52. package/server/mcp/PagePromptComposer.ts +4 -7
  53. package/server/oauth/OAuthClientIdMetadata.ts +29 -6
  54. package/server/oauth/OAuthRegistration.ts +9 -0
  55. package/server/ops/failureLimiter.ts +42 -0
  56. package/server/ops/opsCommand.ts +19 -7
  57. package/server/ops/opsRoute.ts +60 -9
  58. package/server/ops/opsToken.ts +5 -0
  59. package/server/ops/snapshotJobs.ts +3 -1
  60. package/server/ops/snapshotRestore.ts +23 -1
  61. package/server/ops/sqliteSnapshot.ts +98 -18
  62. package/server/ops/statusAccess.ts +37 -19
  63. package/server/proxy/hostBasePathWebProxy.ts +4 -7
  64. package/server/proxy/localeWebProxy.ts +36 -16
  65. package/server/resolver/database.resolver.ts +15 -11
  66. package/server/resolver/service.resolver.ts +17 -2
  67. package/server/resolver/signal.resolver.ts +50 -40
  68. package/server/routeElementComposer.tsx +42 -9
  69. package/server/routeShellGate.ts +42 -0
  70. package/server/routing/apiRouter.ts +64 -1
  71. package/server/routing/serverLimits.ts +151 -0
  72. package/server/routing/soloAppRoutes.ts +23 -12
  73. package/server/rscClient.tsx +5 -3
  74. package/server/rscScrollRestoration.ts +60 -9
  75. package/server/rscWorker.tsx +76 -24
  76. package/server/sitemap.ts +5 -7
  77. package/server/ssrFromRscRenderer.tsx +36 -4
  78. package/server/ssrTypes.ts +5 -2
  79. package/server/staticPath.ts +36 -1
  80. package/server/systemPageDocument.tsx +5 -3
  81. package/server/systemPages.tsx +14 -8
  82. package/server/tunnel/TunnelAgent.ts +19 -2
  83. package/server/tunnel/TunnelDataSocket.ts +4 -2
  84. package/server/tunnel/TunnelHttpStream.ts +7 -2
  85. package/server/tunnel/TunnelTcpStream.ts +6 -3
  86. package/server/tunnel/TunnelWebsocketStream.ts +9 -2
  87. package/server/tunnel/tunnelStream.ts +6 -0
  88. package/server/types.tsx +20 -2
  89. package/server/webRouter.ts +38 -18
  90. package/service/agent.service.ts +9 -7
  91. package/service/injectInfo.ts +2 -1
  92. package/service/liveSyncHub.ts +6 -0
  93. package/service/predefinedAdaptor/anthropicLlm.ts +13 -12
  94. package/service/predefinedAdaptor/database.adaptor.ts +4 -0
  95. package/service/predefinedAdaptor/llm.adaptor.ts +3 -0
  96. package/service/predefinedAdaptor/llmOverflow.ts +5 -1
  97. package/service/predefinedAdaptor/openaiDialect.ts +9 -8
  98. package/service/predefinedAdaptor/searchIndex.ts +21 -1
  99. package/service/predefinedAdaptor/sql/QueryCompiler.ts +28 -4
  100. package/service/predefinedAdaptor/sql/SqlDocumentStore.ts +45 -11
  101. package/service/predefinedAdaptor/sql/dialect/postgres.ts +6 -1
  102. package/service/predefinedAdaptor/sql/types.ts +3 -0
  103. package/service/predefinedAdaptor/storage.adaptor.ts +11 -10
  104. package/service/serve.ts +3 -3
  105. package/service/serviceModule.ts +28 -24
  106. package/service/types.ts +3 -0
  107. package/signal/SignalFailure.ts +5 -2
  108. package/signal/agent/AgentCatalogue.ts +3 -2
  109. package/signal/agentMeter.ts +81 -9
  110. package/signal/agentRelayPolicy.ts +53 -2
  111. package/signal/agentTurnStream.ts +12 -1
  112. package/signal/guard.ts +2 -2
  113. package/signal/mcp/McpDocument.ts +3 -3
  114. package/signal/mcp/McpUriTemplate.ts +5 -5
  115. package/signal/middleware.ts +15 -1
  116. package/signal/roomKey.ts +53 -0
  117. package/signal/signalContext.ts +100 -40
  118. package/signal/slice.ts +27 -22
  119. package/signal/trace.ts +17 -6
  120. package/store/action.ts +20 -49
  121. package/store/actionTag.ts +2 -0
  122. package/store/agent/ScreenReader.ts +36 -4
  123. package/store/agent/ScreenTarget.ts +1 -1
  124. package/store/agent/StoreCatalogue.ts +7 -1
  125. package/store/agent/StoreSurfaceSource.ts +5 -0
  126. package/store/agentic/AgentConfirm.ts +30 -0
  127. package/store/agentic/StStateBuilder.ts +2 -2
  128. package/store/agentic/StToolBuilder.ts +7 -2
  129. package/store/agentic/formFields.ts +1 -2
  130. package/store/agentic/index.ts +1 -0
  131. package/store/agentic/useFieldTool.ts +4 -3
  132. package/store/deprecatedNames.ts +6 -0
  133. package/store/draftStore.ts +2 -2
  134. package/store/formUploadOf.ts +6 -5
  135. package/store/index.ts +1 -2
  136. package/store/sliceKeys.ts +9 -32
  137. package/store/state.ts +1 -1
  138. package/store/stateBuilder.ts +1 -1
  139. package/store/store.ts +1 -1
  140. package/store/storeInstance.ts +12 -8
  141. package/store/storeRegistry.ts +14 -2
  142. package/types/client/cookie.d.ts +7 -2
  143. package/types/client/csrTypes.d.ts +1 -1
  144. package/types/client/router.d.ts +4 -1
  145. package/types/common/akanNames.d.ts +149 -0
  146. package/types/common/codeAgentProfile.d.ts +4 -1
  147. package/types/common/index.d.ts +3 -1
  148. package/types/common/localeConfig.d.ts +8 -0
  149. package/types/common/localePath.d.ts +13 -0
  150. package/types/common/websocketContract.d.ts +7 -0
  151. package/types/constant/index.d.ts +1 -0
  152. package/types/constant/requestValue.d.ts +5 -0
  153. package/types/dictionary/base.dictionary.d.ts +1 -1
  154. package/types/dictionary/dictionary.d.ts +3 -3
  155. package/types/document/database.d.ts +1 -0
  156. package/types/document/documentQuery.d.ts +3 -0
  157. package/types/document/filterMeta.d.ts +4 -0
  158. package/types/fetch/client/sliceInitHandle.d.ts +1 -2
  159. package/types/fetch/index.d.ts +1 -0
  160. package/types/fetch/sessionCookie.d.ts +12 -0
  161. package/types/index.d.ts +8 -0
  162. package/types/server/akanApp.d.ts +1 -0
  163. package/types/server/assetHeaders.d.ts +5 -0
  164. package/types/server/cachePolicy.d.ts +2 -0
  165. package/types/server/devtools/devtoolsRouter.d.ts +1 -1
  166. package/types/server/imageOptimizer.d.ts +4 -1
  167. package/types/server/lifecycle/buildEnv.d.ts +2 -0
  168. package/types/server/lifecycle/loadBuildEnv.d.ts +1 -0
  169. package/types/server/logging/logStreamRoute.d.ts +1 -1
  170. package/types/server/logging/rotatingLogWriter.d.ts +3 -0
  171. package/types/server/machine/machineToken.d.ts +2 -0
  172. package/types/server/mcp/McpAuth.d.ts +2 -1
  173. package/types/server/mcp/McpExecutionContext.d.ts +1 -0
  174. package/types/server/oauth/OAuthRegistration.d.ts +2 -0
  175. package/types/server/ops/failureLimiter.d.ts +11 -0
  176. package/types/server/ops/opsRoute.d.ts +4 -2
  177. package/types/server/ops/opsToken.d.ts +2 -0
  178. package/types/server/ops/snapshotRestore.d.ts +3 -1
  179. package/types/server/ops/sqliteSnapshot.d.ts +7 -1
  180. package/types/server/ops/statusAccess.d.ts +8 -4
  181. package/types/server/proxy/hostBasePathWebProxy.d.ts +1 -0
  182. package/types/server/resolver/signal.resolver.d.ts +0 -1
  183. package/types/server/routeElementComposer.d.ts +10 -4
  184. package/types/server/routeShellGate.d.ts +12 -0
  185. package/types/server/routing/apiRouter.d.ts +6 -1
  186. package/types/server/routing/serverLimits.d.ts +23 -0
  187. package/types/server/routing/soloAppRoutes.d.ts +1 -1
  188. package/types/server/rscScrollRestoration.d.ts +1 -0
  189. package/types/server/ssrTypes.d.ts +5 -2
  190. package/types/server/staticPath.d.ts +1 -0
  191. package/types/server/systemPageDocument.d.ts +1 -0
  192. package/types/server/tunnel/TunnelAgent.d.ts +6 -0
  193. package/types/server/tunnel/TunnelDataSocket.d.ts +2 -0
  194. package/types/server/tunnel/TunnelTcpStream.d.ts +1 -1
  195. package/types/server/tunnel/tunnelStream.d.ts +1 -0
  196. package/types/server/types.d.ts +11 -2
  197. package/types/service/agent.service.d.ts +1 -1
  198. package/types/service/liveSyncHub.d.ts +1 -0
  199. package/types/service/predefinedAdaptor/anthropicLlm.d.ts +1 -1
  200. package/types/service/predefinedAdaptor/llm.adaptor.d.ts +1 -0
  201. package/types/service/predefinedAdaptor/openaiDialect.d.ts +1 -1
  202. package/types/service/predefinedAdaptor/searchIndex.d.ts +5 -1
  203. package/types/service/predefinedAdaptor/sql/SqlDocumentStore.d.ts +1 -0
  204. package/types/service/predefinedAdaptor/sql/types.d.ts +3 -0
  205. package/types/service/types.d.ts +3 -0
  206. package/types/signal/SignalFailure.d.ts +1 -1
  207. package/types/signal/agentMeter.d.ts +4 -1
  208. package/types/signal/agentRelayPolicy.d.ts +12 -0
  209. package/types/signal/guard.d.ts +1 -1
  210. package/types/signal/roomKey.d.ts +8 -0
  211. package/types/signal/signalContext.d.ts +20 -6
  212. package/types/signal/slice.d.ts +1 -0
  213. package/types/signal/trace.d.ts +4 -0
  214. package/types/store/actionTag.d.ts +2 -0
  215. package/types/store/agent/ScreenReader.d.ts +2 -1
  216. package/types/store/agent/StoreCatalogue.d.ts +5 -0
  217. package/types/store/agentic/AgentConfirm.d.ts +15 -0
  218. package/types/store/agentic/StToolBuilder.d.ts +3 -1
  219. package/types/store/agentic/index.d.ts +1 -0
  220. package/types/store/deprecatedNames.d.ts +5 -0
  221. package/types/store/index.d.ts +1 -2
  222. package/types/store/sliceKeys.d.ts +12 -2
  223. package/types/ui/Agent/agentSessionOf.d.ts +2 -0
  224. package/types/ui/Agent/useAccountBoundSession.d.ts +2 -0
  225. package/types/ui/Agent/useScreenActions.d.ts +1 -1
  226. package/types/ui/Icon.d.ts +15 -0
  227. package/types/ui/animated.d.ts +3 -3
  228. package/types/ui/sliceNamesOf.d.ts +4 -4
  229. package/types/vendor/@akanjs/native/packages/cli/src/config.d.ts +10 -0
  230. package/types/vendor/@akanjs/native/packages/cli/src/lib/updates.d.ts +6 -0
  231. package/types/vendor/@akanjs/native/packages/core/src/kernel.d.ts +6 -1
  232. package/types/vendor/@akanjs/native/packages/desktop/src/paths.d.ts +7 -0
  233. package/types/vendor/@akanjs/native/plugins/single-instance/src/socket.d.ts +2 -0
  234. package/types/vendor/@akanjs/native/plugins/updates/src/desktop.d.ts +2 -0
  235. package/types/vendor/use-agentic/AgentSession.d.ts +6 -1
  236. package/types/vendor/use-agentic/ToolRunner.d.ts +7 -2
  237. package/types/vendor/use-agentic/types.d.ts +2 -0
  238. package/types/vendor/use-agentic/useAgentTool.d.ts +2 -1
  239. package/types/webkit/useCsrValues.d.ts +3 -3
  240. package/ui/Agent/Chat.tsx +2 -0
  241. package/ui/Agent/Zone.tsx +2 -0
  242. package/ui/Agent/agentSessionOf.ts +54 -5
  243. package/ui/Agent/useAccountBoundSession.ts +26 -0
  244. package/ui/Agent/useScreenActions.ts +1 -1
  245. package/ui/BottomSheet.tsx +1 -1
  246. package/ui/Button.tsx +5 -5
  247. package/ui/Data/Item.tsx +2 -8
  248. package/ui/Data/ListContainer.tsx +4 -4
  249. package/ui/Data/QueryMaker.tsx +3 -2
  250. package/ui/Data/RefPicker.tsx +3 -2
  251. package/ui/Data/UnlistedEditModal.tsx +2 -1
  252. package/ui/Data/ViewActions.tsx +4 -4
  253. package/ui/Data/dataExport.ts +3 -0
  254. package/ui/Data/useViewModal.ts +10 -15
  255. package/ui/Dialog/LegacyModal.tsx +1 -1
  256. package/ui/Dialog/Provider.tsx +2 -2
  257. package/ui/DragAction.tsx +1 -1
  258. package/ui/Dropdown.tsx +2 -2
  259. package/ui/Field/lightRefCache.ts +2 -2
  260. package/ui/Field/useRelationOptions.ts +3 -10
  261. package/ui/Icon.tsx +87 -0
  262. package/ui/KeyboardAvoiding.tsx +1 -1
  263. package/ui/Layout/Sider.tsx +3 -3
  264. package/ui/Link/Anchor.tsx +4 -1
  265. package/ui/Load/Edit.tsx +2 -2
  266. package/ui/Load/Edit_Client.tsx +3 -3
  267. package/ui/Load/Pagination.tsx +3 -3
  268. package/ui/Load/View.tsx +15 -14
  269. package/ui/Loading/ProgressBar.tsx +1 -1
  270. package/ui/Model/DraftBar.tsx +7 -14
  271. package/ui/Model/EditModal.tsx +9 -40
  272. package/ui/Model/EditWrapper.tsx +3 -5
  273. package/ui/Model/NewWrapper_Client.tsx +3 -7
  274. package/ui/Model/Remove.tsx +2 -2
  275. package/ui/Model/RemoveWrapper.tsx +2 -4
  276. package/ui/Model/SureToRemove.tsx +2 -2
  277. package/ui/Model/ViewEditModal.tsx +9 -20
  278. package/ui/Model/ViewModal.tsx +4 -11
  279. package/ui/Model/ViewWrapper.tsx +4 -6
  280. package/ui/ScreenNavigator.tsx +2 -2
  281. package/ui/Signal/WebSocket.tsx +2 -2
  282. package/ui/System/CSR.tsx +3 -3
  283. package/ui/System/Reconnect.tsx +2 -2
  284. package/ui/System/SSR.tsx +2 -2
  285. package/ui/System/SelectLanguage.tsx +1 -1
  286. package/ui/System/ThemeToggle.tsx +1 -1
  287. package/ui/Tab/Provider.tsx +1 -0
  288. package/ui/Toast.tsx +7 -15
  289. package/ui/index.ts +0 -1
  290. package/ui/sliceNamesOf.ts +25 -15
  291. package/vendor/@akanjs/native/native/android/src/com/akanjs/runtime/AkanNativeActivity.kt +2 -1
  292. package/vendor/@akanjs/native/native/android/src/com/akanjs/runtime/AkanNativeKernel.kt +10 -2
  293. package/vendor/@akanjs/native/native/android/src/com/akanjs/runtime/AkanNativeVectors.kt +4 -0
  294. package/vendor/@akanjs/native/native/desktop/src/acl.rs +166 -0
  295. package/vendor/@akanjs/native/native/desktop/src/lib.rs +25 -6
  296. package/vendor/@akanjs/native/native/desktop/src/navigation.rs +22 -10
  297. package/vendor/@akanjs/native/native/desktop/src/routes.rs +2 -2
  298. package/vendor/@akanjs/native/native/desktop/src/vectors.rs +28 -0
  299. package/vendor/@akanjs/native/native/ios/Sources/AkanNativeBridge.swift +1 -1
  300. package/vendor/@akanjs/native/native/ios/Sources/AkanNativeKernel.swift +10 -2
  301. package/vendor/@akanjs/native/native/ios/Sources/AkanNativeVectors.swift +4 -0
  302. package/vendor/@akanjs/native/native/ios/Sources/AkanNativeViewController.swift +1 -0
  303. package/vendor/@akanjs/native/packages/cli/src/config.ts +10 -0
  304. package/vendor/@akanjs/native/packages/cli/src/lib/configkeys.ts +2 -0
  305. package/vendor/@akanjs/native/packages/cli/src/lib/csp.ts +5 -3
  306. package/vendor/@akanjs/native/packages/cli/src/lib/prepare.ts +4 -0
  307. package/vendor/@akanjs/native/packages/cli/src/lib/publish.ts +2 -0
  308. package/vendor/@akanjs/native/packages/cli/src/lib/updates.ts +30 -0
  309. package/vendor/@akanjs/native/packages/cli/src/lib/vectors.ts +1 -1
  310. package/vendor/@akanjs/native/packages/core/src/kernel.ts +18 -2
  311. package/vendor/@akanjs/native/packages/core/vectors/navigation.json +22 -1
  312. package/vendor/@akanjs/native/packages/core/vectors/routes.json +4 -0
  313. package/vendor/@akanjs/native/packages/desktop/src/main.ts +5 -1
  314. package/vendor/@akanjs/native/packages/desktop/src/paths.ts +24 -1
  315. package/vendor/@akanjs/native/plugins/external-view/src/desktop.ts +4 -5
  316. package/vendor/@akanjs/native/plugins/file-picker/src/desktop.ts +6 -8
  317. package/vendor/@akanjs/native/plugins/filesystem/src/desktop.ts +4 -2
  318. package/vendor/@akanjs/native/plugins/single-instance/src/socket.ts +22 -3
  319. package/vendor/@akanjs/native/plugins/updates/android/UpdatesPlugin.kt +6 -0
  320. package/vendor/@akanjs/native/plugins/updates/ios/UpdatesPlugin.swift +6 -0
  321. package/vendor/@akanjs/native/plugins/updates/src/desktop.ts +106 -18
  322. package/vendor/use-agentic/AgentSession.ts +7 -2
  323. package/vendor/use-agentic/ToolRunner.ts +44 -5
  324. package/vendor/use-agentic/types.ts +2 -0
  325. package/vendor/use-agentic/useAgentTool.ts +4 -2
  326. package/webkit/McpAppAgent.ts +2 -1
  327. package/webkit/bootCsr.tsx +1 -1
  328. package/webkit/useCsrValues.ts +5 -1
  329. package/webkit/usePageTool.tsx +1 -0
  330. package/store/databaseStateNames.ts +0 -24
  331. package/store/formSetterNames.ts +0 -18
  332. package/types/store/databaseStateNames.d.ts +0 -18
  333. 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.text().catch(() => "");
68
- if (!text || text.length > OAuthClientIdMetadata.maxBytes) return null;
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
- const grantTypes = Array.isArray(doc.grant_types)
89
- ? doc.grant_types.filter((g) => typeof g === "string")
90
- : undefined;
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: grantTypes?.length ? grantTypes : ["authorization_code"],
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
+ }
@@ -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 (!process.env.AKAN_BACKUP_RECIPIENT?.trim()) return null;
134
- const { AgeEncryption } = await import("./ageEncryption");
135
- return await AgeEncryption.fromEnv();
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) {
@@ -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({ verifier, detail, jobs, migrateJobs, rateLimitPerMinute = 30 }: OpsRouteOptions) {
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
- verifier = new OpsTokenVerifier(
52
- OpsTokenVerifier.parsePublicKey(publicKey),
53
- OpsTokenVerifier.audienceOf(appName, environment),
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(`Ops channel enabled for audience ${verifier.audience}`);
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 (!this.#admit()) return OpsRoute.#json({ error: "rate limited" }, 429, { "retry-after": "60" });
132
+ if (this.#failures.blocked(client)) return OpsRoute.#rateLimited();
110
133
  const verdict = this.#verifier.verifyRequest(req);
111
134
  if (!verdict.ok) {
112
- this.#logger.warn(`Refused ops call to ${url.pathname}: ${verdict.reason}`);
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
  }
@@ -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({ manifestPath, sources, decryptor = null, now = new Date() }: SnapshotRestoreOptions) {
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(targetDir, { recursive: true });
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, packedPath } = await SqliteSnapshot.#captureOne(role, source, targetDir, encryptor);
87
+ const { file, problems } = await SqliteSnapshot.#captureOne(role, source, staging, encryptor, fileMode);
66
88
  files.push(file);
67
- paths[role] = packedPath;
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
- const manifestPath = path.join(targetDir, SqliteSnapshot.manifestName);
85
- await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
86
- return { dir: targetDir, manifest, manifestPath, paths };
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(targetDir, { recursive: true, force: true });
114
+ await rm(staging, { recursive: true, force: true });
89
115
  throw error;
90
116
  }
91
117
  }
92
118
 
93
- static async #captureOne(role: SnapshotFileRole, source: string, dir: string, encryptor: SnapshotEncryptor | null) {
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
- const stages: NodeJS.ReadWriteStream[] = [SqliteSnapshot.tap(sourceHash), createGzip()];
105
- if (encryptor) stages.push(encryptor.transform());
106
- stages.push(SqliteSnapshot.tap(uploadHash));
107
- await pipeline([createReadStream(rawPath), ...stages, createWriteStream(packedPath)]);
108
- await rm(rawPath, { force: true });
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,