@plitzi/sdk-server 0.33.2 → 0.34.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 (274) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +283 -8
  3. package/dist/actions.d.ts +62 -0
  4. package/dist/actions.js +12 -0
  5. package/dist/adapters/cloudAdapters.d.ts +72 -0
  6. package/dist/adapters/cloudAdapters.js +303 -0
  7. package/dist/adapters/space/index.d.ts +93 -0
  8. package/dist/adapters/space/index.js +122 -0
  9. package/dist/adapters/space/resolvers.d.ts +69 -0
  10. package/dist/adapters/space/resolvers.js +79 -0
  11. package/dist/adapters/space/space.test.d.ts +1 -0
  12. package/dist/adapters/space/types.d.ts +64 -0
  13. package/dist/adapters/space/types.js +41 -0
  14. package/dist/auth.d.ts +50 -0
  15. package/dist/auth.js +19 -0
  16. package/dist/core/auth/api.d.ts +400 -11
  17. package/dist/core/auth/api.js +1044 -50
  18. package/dist/core/auth/createAuth.d.ts +135 -30
  19. package/dist/core/auth/createAuth.js +96 -24
  20. package/dist/core/auth/credentials.d.ts +9 -3
  21. package/dist/core/auth/credentials.js +10 -4
  22. package/dist/core/auth/csrf.d.ts +89 -0
  23. package/dist/core/auth/csrf.js +234 -0
  24. package/dist/core/auth/csrf.test.d.ts +1 -0
  25. package/dist/core/auth/identity.d.ts +6 -0
  26. package/dist/core/auth/identity.js +37 -8
  27. package/dist/core/auth/lifecycle.test.d.ts +1 -0
  28. package/dist/core/auth/oauth/index.d.ts +8 -0
  29. package/dist/core/auth/oauth/index.js +13 -15
  30. package/dist/core/auth/passwords.d.ts +15 -0
  31. package/dist/core/auth/passwords.js +32 -0
  32. package/dist/core/auth/redirects.d.ts +18 -0
  33. package/dist/core/auth/redirects.js +31 -0
  34. package/dist/core/auth/redirects.test.d.ts +1 -0
  35. package/dist/core/auth/routes.d.ts +4 -2
  36. package/dist/core/auth/routes.js +234 -9
  37. package/dist/core/auth/session.d.ts +11 -0
  38. package/dist/core/auth/session.js +1 -1
  39. package/dist/core/auth/spaceTokens.d.ts +38 -2
  40. package/dist/core/auth/spaceTokens.js +54 -2
  41. package/dist/core/auth/throttle.d.ts +5 -0
  42. package/dist/core/auth/throttle.js +81 -0
  43. package/dist/core/auth/throttle.test.d.ts +1 -0
  44. package/dist/core/auth/tokens.d.ts +83 -12
  45. package/dist/core/auth/tokens.js +51 -9
  46. package/dist/core/auth/totp.d.ts +48 -0
  47. package/dist/core/auth/totp.js +118 -0
  48. package/dist/core/auth/totp.test.d.ts +1 -0
  49. package/dist/core/createServer.d.ts +0 -6
  50. package/dist/core/createServer.js +35 -3
  51. package/dist/core/fontStore.d.ts +33 -0
  52. package/dist/core/fontStore.js +53 -0
  53. package/dist/core/fontStore.test.d.ts +1 -0
  54. package/dist/core/handlers/authMiddleware.js +1 -1
  55. package/dist/core/handlers/authRouteHandlers.d.ts +9 -1
  56. package/dist/core/handlers/authRouteHandlers.js +4 -3
  57. package/dist/core/handlers/csrfMiddleware.d.ts +20 -0
  58. package/dist/core/handlers/csrfMiddleware.js +32 -0
  59. package/dist/core/handlers/oauthRouteHandlers.d.ts +46 -0
  60. package/dist/core/handlers/oauthRouteHandlers.js +159 -0
  61. package/dist/core/handlers/socialRouteHandlers.d.ts +40 -0
  62. package/dist/core/handlers/socialRouteHandlers.js +129 -0
  63. package/dist/core/handlers/socialRouteHandlers.test.d.ts +1 -0
  64. package/dist/core/handlers/types.d.ts +22 -5
  65. package/dist/core/health.d.ts +11 -1
  66. package/dist/core/health.js +27 -3
  67. package/dist/core/http/dispatcher.d.ts +1 -1
  68. package/dist/core/http/dispatcher.js +20 -1
  69. package/dist/core/http/stages/authApi.js +1 -1
  70. package/dist/core/http/stages/fontAssets.d.ts +9 -0
  71. package/dist/core/http/stages/fontAssets.js +27 -0
  72. package/dist/core/http/stages/oauth.d.ts +19 -0
  73. package/dist/core/http/stages/oauth.js +118 -0
  74. package/dist/core/http/types.d.ts +12 -0
  75. package/dist/core/oauth/authorize.d.ts +22 -0
  76. package/dist/core/oauth/authorize.js +254 -0
  77. package/dist/core/oauth/authorize.test.d.ts +1 -0
  78. package/dist/core/oauth/challenge.d.ts +11 -0
  79. package/dist/core/oauth/challenge.js +33 -0
  80. package/dist/core/oauth/consentPage.d.ts +12 -0
  81. package/dist/core/oauth/consentPage.js +131 -0
  82. package/dist/core/oauth/metadata.d.ts +41 -0
  83. package/dist/core/oauth/metadata.js +71 -0
  84. package/dist/core/oauth/params.d.ts +5 -0
  85. package/dist/core/oauth/params.js +5 -0
  86. package/dist/core/oauth/pkce.d.ts +6 -0
  87. package/dist/core/oauth/pkce.js +15 -0
  88. package/dist/core/oauth/records.d.ts +72 -0
  89. package/dist/core/oauth/records.js +49 -0
  90. package/dist/core/oauth/register.d.ts +5 -0
  91. package/dist/core/oauth/register.js +55 -0
  92. package/dist/core/oauth/respond.d.ts +21 -0
  93. package/dist/core/oauth/respond.js +59 -0
  94. package/dist/core/oauth/token.d.ts +14 -0
  95. package/dist/core/oauth/token.js +130 -0
  96. package/dist/core/previewToken.d.ts +20 -0
  97. package/dist/core/previewToken.js +29 -1
  98. package/dist/core/server/pageServer.js +4 -1
  99. package/dist/core/services/action.d.ts +6 -4
  100. package/dist/core/services/action.js +103 -10
  101. package/dist/core/services/registry.js +2 -0
  102. package/dist/core/services/resolve.d.ts +8 -0
  103. package/dist/core/services/resolve.js +12 -1
  104. package/dist/core/staticFiles.js +1 -2
  105. package/dist/handlers.d.ts +7 -1
  106. package/dist/handlers.js +4 -1
  107. package/dist/helpers/buildResponseHelpers.d.ts +8 -0
  108. package/dist/helpers/buildResponseHelpers.js +9 -1
  109. package/dist/helpers/buildServerInfo.d.ts +8 -3
  110. package/dist/helpers/buildServerInfo.js +18 -14
  111. package/dist/helpers/cache/keys.d.ts +13 -0
  112. package/dist/helpers/cache/keys.js +12 -1
  113. package/dist/helpers/onAbort.d.ts +12 -0
  114. package/dist/helpers/onAbort.js +22 -0
  115. package/dist/helpers/onAbort.test.d.ts +1 -0
  116. package/dist/helpers/outboundGuard.d.ts +27 -0
  117. package/dist/helpers/outboundGuard.js +49 -0
  118. package/dist/helpers/outboundGuard.test.d.ts +1 -0
  119. package/dist/helpers/serverLog.d.ts +6 -3
  120. package/dist/helpers/serverLog.js +20 -3
  121. package/dist/index.d.ts +6 -0
  122. package/dist/index.js +9 -4
  123. package/dist/kernel.d.ts +5 -22
  124. package/dist/kernel.js +1 -14
  125. package/dist/modules/actions/{handler.js → connectorWrite.js} +5 -2
  126. package/dist/modules/actions/connectorWrite.test.d.ts +1 -0
  127. package/dist/modules/actions/index.d.ts +40 -0
  128. package/dist/modules/actions/index.js +46 -0
  129. package/dist/modules/actions/moduleFor.d.ts +3 -0
  130. package/dist/modules/actions/moduleFor.js +35 -0
  131. package/dist/modules/actions/moduleFor.test.d.ts +1 -0
  132. package/dist/modules/actions/runtime/check.d.ts +24 -0
  133. package/dist/modules/actions/runtime/check.js +111 -0
  134. package/dist/modules/actions/runtime/check.test.d.ts +1 -0
  135. package/dist/modules/actions/runtime/errors.d.ts +11 -0
  136. package/dist/modules/actions/runtime/errors.js +17 -0
  137. package/dist/modules/actions/runtime/guards.d.ts +126 -0
  138. package/dist/modules/actions/runtime/guards.js +246 -0
  139. package/dist/modules/actions/runtime/guards.test.d.ts +1 -0
  140. package/dist/modules/actions/runtime/kvStore.d.ts +27 -0
  141. package/dist/modules/actions/runtime/kvStore.js +41 -0
  142. package/dist/modules/actions/runtime/kvStore.test.d.ts +1 -0
  143. package/dist/modules/actions/runtime/limits.d.ts +11 -0
  144. package/dist/modules/actions/runtime/limits.js +35 -0
  145. package/dist/modules/actions/runtime/memoryKv.d.ts +2 -0
  146. package/dist/modules/actions/runtime/memoryKv.js +77 -0
  147. package/dist/modules/actions/runtime/namespaceKv.d.ts +9 -0
  148. package/dist/modules/actions/runtime/namespaceKv.js +19 -0
  149. package/dist/modules/actions/runtime/precheck.d.ts +27 -0
  150. package/dist/modules/actions/runtime/precheck.js +45 -0
  151. package/dist/modules/actions/runtime/renderResolver.d.ts +15 -0
  152. package/dist/modules/actions/runtime/renderResolver.js +130 -0
  153. package/dist/modules/actions/runtime/renderResolver.test.d.ts +1 -0
  154. package/dist/modules/actions/runtime/renderShare.d.ts +29 -0
  155. package/dist/modules/actions/runtime/renderShare.js +36 -0
  156. package/dist/modules/actions/runtime/renderShare.test.d.ts +1 -0
  157. package/dist/modules/actions/runtime/report.d.ts +14 -0
  158. package/dist/modules/actions/runtime/report.js +22 -0
  159. package/dist/modules/actions/runtime/runAction.d.ts +12 -0
  160. package/dist/modules/actions/runtime/runAction.js +405 -0
  161. package/dist/modules/actions/runtime/runAction.test.d.ts +1 -0
  162. package/dist/modules/actions/runtime/runLogger.d.ts +31 -0
  163. package/dist/modules/actions/runtime/runLogger.js +66 -0
  164. package/dist/modules/actions/runtime/runLogger.test.d.ts +1 -0
  165. package/dist/modules/actions/runtime/schedule.d.ts +35 -0
  166. package/dist/modules/actions/runtime/schedule.js +76 -0
  167. package/dist/modules/actions/runtime/schedule.test.d.ts +1 -0
  168. package/dist/modules/actions/runtime/scope.d.ts +40 -0
  169. package/dist/modules/actions/runtime/scope.js +103 -0
  170. package/dist/modules/actions/runtime/triggers.d.ts +12 -0
  171. package/dist/modules/actions/runtime/triggers.js +14 -0
  172. package/dist/modules/actions/taskCatalog.d.ts +26 -0
  173. package/dist/modules/actions/taskCatalog.js +24 -0
  174. package/dist/modules/actions/taskCatalog.test.d.ts +1 -0
  175. package/dist/modules/actions/tasks/auth.d.ts +2 -0
  176. package/dist/modules/actions/tasks/auth.js +40 -0
  177. package/dist/modules/actions/tasks/builtins.d.ts +8 -0
  178. package/dist/modules/actions/tasks/builtins.js +24 -0
  179. package/dist/modules/actions/tasks/connector.d.ts +2 -0
  180. package/dist/modules/actions/tasks/connector.js +141 -0
  181. package/dist/modules/actions/tasks/db.d.ts +2 -0
  182. package/dist/modules/actions/tasks/db.js +59 -0
  183. package/dist/modules/actions/tasks/db.test.d.ts +1 -0
  184. package/dist/modules/actions/tasks/flow.d.ts +3 -0
  185. package/dist/modules/actions/tasks/flow.js +109 -0
  186. package/dist/modules/actions/tasks/helpers.d.ts +16 -0
  187. package/dist/modules/actions/tasks/helpers.js +30 -0
  188. package/dist/modules/actions/tasks/http.d.ts +2 -0
  189. package/dist/modules/actions/tasks/http.js +111 -0
  190. package/dist/modules/actions/tasks/http.test.d.ts +1 -0
  191. package/dist/modules/actions/tasks/kv.d.ts +2 -0
  192. package/dist/modules/actions/tasks/kv.js +102 -0
  193. package/dist/modules/actions/tasks/kv.test.d.ts +1 -0
  194. package/dist/modules/actions/tasks/registry.d.ts +10 -0
  195. package/dist/modules/actions/tasks/registry.js +36 -0
  196. package/dist/modules/actions/tasks/transform.d.ts +2 -0
  197. package/dist/modules/actions/tasks/transform.js +34 -0
  198. package/dist/modules/actions/transport/callHandler.d.ts +24 -0
  199. package/dist/modules/actions/transport/callHandler.js +233 -0
  200. package/dist/modules/actions/transport/callHandler.test.d.ts +1 -0
  201. package/dist/modules/actions/transport/cancelHandler.d.ts +23 -0
  202. package/dist/modules/actions/transport/cancelHandler.js +23 -0
  203. package/dist/modules/actions/transport/catalogHandler.d.ts +18 -0
  204. package/dist/modules/actions/transport/catalogHandler.js +21 -0
  205. package/dist/modules/actions/transport/stream.d.ts +23 -0
  206. package/dist/modules/actions/transport/stream.js +100 -0
  207. package/dist/modules/actions/transport/verifySignature.d.ts +16 -0
  208. package/dist/modules/actions/transport/verifySignature.js +72 -0
  209. package/dist/modules/actions/transport/webhookHandler.d.ts +25 -0
  210. package/dist/modules/actions/transport/webhookHandler.js +206 -0
  211. package/dist/modules/actions/transport/webhookHandler.test.d.ts +1 -0
  212. package/dist/modules/actions/types.d.ts +234 -0
  213. package/dist/modules/connectors/engine.js +4 -1
  214. package/dist/modules/connectors/resolver.d.ts +10 -2
  215. package/dist/modules/connectors/resolver.js +8 -3
  216. package/dist/modules/mysql/accounts.d.ts +5 -0
  217. package/dist/modules/mysql/accounts.js +365 -0
  218. package/dist/modules/mysql/accounts.test.d.ts +1 -0
  219. package/dist/modules/mysql/admin.d.ts +58 -0
  220. package/dist/modules/mysql/admin.js +133 -0
  221. package/dist/modules/mysql/config.d.ts +82 -0
  222. package/dist/modules/mysql/config.js +30 -0
  223. package/dist/modules/mysql/identities.d.ts +35 -0
  224. package/dist/modules/mysql/identities.js +43 -0
  225. package/dist/modules/mysql/index.d.ts +67 -0
  226. package/dist/modules/mysql/index.js +62 -0
  227. package/dist/modules/mysql/pool.d.ts +3 -0
  228. package/dist/modules/mysql/pool.js +88 -0
  229. package/dist/modules/mysql/query.d.ts +12 -0
  230. package/dist/modules/mysql/query.js +21 -0
  231. package/dist/modules/mysql/schema.d.ts +43 -0
  232. package/dist/modules/mysql/schema.js +340 -0
  233. package/dist/modules/mysql/schema.test.d.ts +1 -0
  234. package/dist/modules/mysql/sessions.d.ts +33 -0
  235. package/dist/modules/mysql/sessions.js +46 -0
  236. package/dist/modules/mysql/spaceTokens.d.ts +11 -0
  237. package/dist/modules/mysql/spaceTokens.js +59 -0
  238. package/dist/modules/mysql/store.integration.test.d.ts +1 -0
  239. package/dist/modules/rsc/connectorRscData.d.ts +25 -0
  240. package/dist/modules/rsc/connectorRscData.js +51 -0
  241. package/dist/modules/rsc/connectorRscData.test.d.ts +1 -0
  242. package/dist/modules/rsc/handler.js +16 -3
  243. package/dist/modules/rsc/matchRscPage.d.ts +14 -0
  244. package/dist/modules/rsc/matchRscPage.js +26 -0
  245. package/dist/modules/rsc/resolveRscData.d.ts +14 -1
  246. package/dist/modules/rsc/resolveRscData.js +23 -34
  247. package/dist/modules/ssr/Component.d.ts +6 -2
  248. package/dist/modules/ssr/Component.js +3 -1
  249. package/dist/modules/ssr/draftSession.test.d.ts +1 -0
  250. package/dist/modules/ssr/handler.js +15 -2
  251. package/dist/modules/ssr/loadPluginComponents.js +22 -5
  252. package/dist/modules/ssr/prepareRender.js +87 -8
  253. package/dist/modules/ssr/prepareRender.test.d.ts +1 -0
  254. package/dist/modules/ssr/preview.d.ts +21 -8
  255. package/dist/modules/ssr/preview.js +38 -14
  256. package/dist/modules/ssr/registerExternalPlugins.js +2 -2
  257. package/dist/modules/ssr/resolvePageSeo.d.ts +18 -0
  258. package/dist/modules/ssr/resolvePageSeo.js +26 -0
  259. package/dist/modules/ssr/resolvePageSeo.test.d.ts +1 -0
  260. package/dist/modules/ssr/template.test.d.ts +1 -0
  261. package/dist/modules/ssr/views/template.ejs +71 -5
  262. package/dist/mysql.d.ts +26 -0
  263. package/dist/mysql.js +4 -0
  264. package/dist/oauth.d.ts +24 -0
  265. package/dist/oauth.js +10 -0
  266. package/dist/plugins/compile.d.ts +1 -0
  267. package/dist/plugins/compile.js +37 -3
  268. package/dist/plugins/manager.d.ts +40 -0
  269. package/dist/plugins/manager.js +113 -13
  270. package/dist/plugins/manager.test.d.ts +1 -0
  271. package/package.json +43 -14
  272. package/skills/plitzi-authoring/SKILL.md +252 -0
  273. /package/dist/{modules/actions/handler.test.d.ts → adapters/cloudAdapters.test.d.ts} +0 -0
  274. /package/dist/modules/actions/{handler.d.ts → connectorWrite.d.ts} +0 -0
@@ -0,0 +1,246 @@
1
+ import { ActionRunError } from "./errors.js";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ //#region src/modules/actions/runtime/guards.ts
4
+ var DEFAULTS = {
5
+ perSpace: 10,
6
+ perProcess: 100,
7
+ renderPerProcess: 1e3
8
+ };
9
+ /**
10
+ * How long a cancellation waits to be read, when the replica that owns the run is not the one that took the
11
+ * request.
12
+ *
13
+ * Longer than any run's own timeout would be pointless — the run it addresses is gone — and shorter risks a flag
14
+ * expiring before a slow step finishes. A streaming run's ceiling is two minutes, so this covers it with room.
15
+ */
16
+ var CANCEL_FLAG_TTL_SECONDS = 180;
17
+ /** Kept away from the `kv` tasks' own prefix: a flow writing `run:…` must not be able to release a run. */
18
+ var keyPrefix = "action:run:";
19
+ /**
20
+ * Where a cancellation is left for whichever replica is actually running the flow.
21
+ *
22
+ * Exported because the RUNNER is what reads it, at every step boundary, and the two must not each spell the key
23
+ * their own way — a cancel written under one name and read under another is a stop button that does nothing.
24
+ */
25
+ var runCancelKey = (runId) => `${keyPrefix}cancel:${runId}`;
26
+ /** Who started a run, so a cancel arriving at another replica can still check ownership. */
27
+ var runOwnerKey = (runId) => `${keyPrefix}owner:${runId}`;
28
+ /** The answer a finished run left behind, for a caller that asks again with the same key. */
29
+ var runResultKey = (runKey) => `${keyPrefix}done:${runKey}`;
30
+ /** Stable regardless of key order, so the same call from the same caller derives the same key every time. */
31
+ var canonical = (value) => {
32
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
33
+ if (value !== null && typeof value === "object") return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`).join(",")}}`;
34
+ if (value === void 0) return "null";
35
+ return JSON.stringify(value);
36
+ };
37
+ var deriveRunKey = ({ spaceId, actionId, callerId, input, idempotencyKey, sharedKey }) => {
38
+ if (idempotencyKey) return sharedKey ? `${spaceId}:${actionId}:${idempotencyKey}` : `${spaceId}:${actionId}:${callerId}:${idempotencyKey}`;
39
+ return `${spaceId}:${actionId}:${createHash("sha256").update(canonical({
40
+ callerId,
41
+ input
42
+ })).digest("hex").slice(0, 32)}`;
43
+ };
44
+ /**
45
+ * Single-flight, concurrency caps and cancellation, in one place.
46
+ *
47
+ * The client engine already refuses re-entry — `InteractionsManager` drops a trigger that fires while its own flow
48
+ * is still running — and this is the same rule keyed by what identifies a RUN rather than a DOM event. Without it
49
+ * a client that retries, reconnects or simply double-clicks turns one intent into many runs, and a streaming
50
+ * caller can turn it into an unbounded loop.
51
+ *
52
+ * **What is shared and what is not**, when a store is handed over:
53
+ *
54
+ * - **Single-flight is**, because per replica it is not a guarantee at all: the same double-click behind a load
55
+ * balancer lands on two of them and both run. The key is taken with `increment`, which is the one atomic
56
+ * test-and-set every adapter already has to provide — the holder is whoever's increment answered `1` — and it
57
+ * carries the run's own timeout as its lifetime, so a replica that dies holding one frees it by expiring.
58
+ * - **The caps are not.** Counting them across replicas needs a decrementing counter, and a run that dies without
59
+ * decrementing throttles a healthy space until somebody notices. A ceiling that is per replica is a number a
60
+ * deployment can multiply and reason about; one that leaks is not. So `perSpace` is per replica, deliberately,
61
+ * and a cluster's real ceiling is that times the number of them.
62
+ * - **Cancellation is**, in one direction. Only the process running a flow holds the `AbortController` that stops
63
+ * it, so a `DELETE` that lands anywhere else leaves a FLAG in the store and the run reads it at its next step
64
+ * boundary. Behind a load balancer that is the normal case rather than the exotic one: the tab that wants to
65
+ * stop a checkout almost never reaches the replica running it.
66
+ * - **A finished answer is**, when the caller named the key and the deployment set a replay window.
67
+ */
68
+ var createRunGuards = (config = {}, store, idempotency = {}) => {
69
+ const perSpace = config.perSpace ?? DEFAULTS.perSpace;
70
+ const perProcess = config.perProcess ?? DEFAULTS.perProcess;
71
+ const renderPerProcess = config.renderPerProcess ?? DEFAULTS.renderPerProcess;
72
+ const replayTtlMs = idempotency.replayTtlMs ?? 0;
73
+ const byKey = /* @__PURE__ */ new Map();
74
+ const byId = /* @__PURE__ */ new Map();
75
+ /** The no-store half of replay: one process remembering its own answers, which is what one process can honestly
76
+ * offer. With a store it is never read — a cluster's replays have to be everybody's. */
77
+ const localResults = /* @__PURE__ */ new Map();
78
+ const sweep = () => {
79
+ const now = Date.now();
80
+ byKey.forEach((run) => {
81
+ if (run.expiresAt <= now) {
82
+ byKey.delete(run.runKey);
83
+ byId.delete(run.runId);
84
+ }
85
+ });
86
+ localResults.forEach((entry, key) => {
87
+ if (entry.expiresAt <= now) localResults.delete(key);
88
+ });
89
+ };
90
+ const count = (matches) => {
91
+ let total = 0;
92
+ byKey.forEach((run) => {
93
+ if (matches(run)) total += 1;
94
+ });
95
+ return total;
96
+ };
97
+ /**
98
+ * Takes the key, here and — when there is one — everywhere.
99
+ *
100
+ * `increment` is the primitive because it is the only atomic one the adapter contract has, and it says exactly
101
+ * what is needed: the caller whose increment answers `1` created the key and holds it. The lifetime is set by
102
+ * that same caller, which is the rule `createKvStore` already follows for a rate-limit window; the gap between
103
+ * the two calls is a key that outlives its holder if the process dies inside it, and it is the same gap that
104
+ * idiom has always had.
105
+ */
106
+ const ttlSeconds = (ttlMs) => Math.max(1, Math.ceil(ttlMs / 1e3));
107
+ const takeShared = async (runKey, ttlMs) => {
108
+ if (!store) return true;
109
+ if (await store.increment(`${keyPrefix}${runKey}`, 1) !== 1) return false;
110
+ await store.expire(`${keyPrefix}${runKey}`, ttlSeconds(ttlMs));
111
+ return true;
112
+ };
113
+ const begin = async (params) => {
114
+ sweep();
115
+ const runKey = deriveRunKey(params);
116
+ const existing = byKey.get(runKey);
117
+ if (existing) throw new ActionRunError("duplicate", `This action is already running as ${existing.runId}`);
118
+ /**
119
+ * Two budgets, because they are two different risks.
120
+ *
121
+ * A call is counted against its space AND the process: one space's callers must not starve another's, and
122
+ * neither may take the box down. A render is counted against the PROCESS alone — it arrives because people
123
+ * are reading the page, so a per-space ceiling would refuse the visitor who made the space worth having.
124
+ */
125
+ const kind = params.kind ?? "call";
126
+ if (kind === "render" ? count((run) => run.kind === "render") >= renderPerProcess : count((run) => run.kind === "call") >= perProcess || count((run) => run.kind === "call" && run.spaceId === params.spaceId) >= perSpace) throw new ActionRunError("over_capacity", "Too many actions are running right now");
127
+ const run = {
128
+ runId: randomUUID(),
129
+ runKey,
130
+ kind,
131
+ spaceId: params.spaceId,
132
+ actionId: params.actionId,
133
+ callerId: params.callerId,
134
+ controller: new AbortController(),
135
+ expiresAt: Date.now() + params.ttlMs,
136
+ explicitKey: Boolean(params.idempotencyKey)
137
+ };
138
+ /**
139
+ * The local slot is taken BEFORE anything is awaited, and that ordering is the guarantee.
140
+ *
141
+ * Everything above this line runs in one synchronous stretch, so two callers in the same process cannot both
142
+ * pass it — which is what made this work when `begin` was synchronous throughout. Reach for the store first
143
+ * and both of them would sail through the local check while the other was suspended on it.
144
+ */
145
+ byKey.set(runKey, run);
146
+ byId.set(run.runId, run);
147
+ try {
148
+ if (!await takeShared(runKey, params.ttlMs)) throw new ActionRunError("duplicate", "This action is already running");
149
+ } catch (error) {
150
+ byKey.delete(runKey);
151
+ byId.delete(run.runId);
152
+ throw error;
153
+ }
154
+ await store?.set(runOwnerKey(run.runId), params.callerId, ttlSeconds(params.ttlMs)).catch(() => void 0);
155
+ return run;
156
+ };
157
+ /**
158
+ * Remembers what a run answered, for a caller that asks again by the same key.
159
+ *
160
+ * Only a COMPLETED run, and only a key the caller named. A failure is not replayed because a retry after one is
161
+ * the reasonable thing to do — pinning a transient outage for the whole window would turn a blip into an hour
162
+ * of it — and a derived key would make two deliberate identical calls into one.
163
+ */
164
+ const remember = async (run, outcome) => {
165
+ if (replayTtlMs <= 0 || !run.explicitKey || outcome.status !== "completed") return;
166
+ const answer = {
167
+ runId: outcome.runId,
168
+ status: outcome.status,
169
+ output: outcome.output
170
+ };
171
+ if (!store) {
172
+ localResults.set(run.runKey, {
173
+ expiresAt: Date.now() + replayTtlMs,
174
+ result: answer
175
+ });
176
+ return;
177
+ }
178
+ await store.set(runResultKey(run.runKey), JSON.stringify(answer), ttlSeconds(replayTtlMs)).catch(() => void 0);
179
+ };
180
+ const end = async (run, outcome) => {
181
+ byKey.delete(run.runKey);
182
+ byId.delete(run.runId);
183
+ if (outcome) await remember(run, outcome);
184
+ await store?.delete(`${keyPrefix}${run.runKey}`).catch(() => void 0);
185
+ await store?.delete(runOwnerKey(run.runId)).catch(() => void 0);
186
+ await store?.delete(runCancelKey(run.runId)).catch(() => void 0);
187
+ };
188
+ /**
189
+ * The answer this key already produced, if it is still inside the window.
190
+ *
191
+ * Asked BEFORE a slot is taken, so a retry of something that already happened costs nothing and — this is the
192
+ * point — does not happen twice. Single-flight covers the retry that arrives while the first run is going; this
193
+ * covers the one that arrives a minute after it finished, which is the shape every webhook provider retries in.
194
+ */
195
+ const replay = async (params) => {
196
+ if (replayTtlMs <= 0 || !params.idempotencyKey) return;
197
+ const runKey = deriveRunKey(params);
198
+ if (!store) {
199
+ sweep();
200
+ return localResults.get(runKey)?.result;
201
+ }
202
+ try {
203
+ const stored = await store.get(runResultKey(runKey));
204
+ return stored ? JSON.parse(stored) : void 0;
205
+ } catch {
206
+ return;
207
+ }
208
+ };
209
+ /**
210
+ * Only the caller that started a run may stop it.
211
+ *
212
+ * A run id is a bearer string that travels to the browser, and without this check anyone holding one could stop
213
+ * another visitor's checkout — a denial of service addressed at a single person.
214
+ *
215
+ * Local first, because a cancel that reaches the right replica should stop the flow at its next boundary rather
216
+ * than a store round trip later. When it does not — the usual case behind a load balancer — the request leaves
217
+ * the flag the running replica polls, and ownership is checked against what `begin` published.
218
+ */
219
+ const cancel = async (runId, callerId) => {
220
+ const run = byId.get(runId);
221
+ if (run) {
222
+ if (run.callerId !== callerId) return false;
223
+ run.controller.abort();
224
+ await store?.set(runCancelKey(runId), "1", ttlSeconds(run.expiresAt - Date.now())).catch(() => void 0);
225
+ return true;
226
+ }
227
+ if (!store) return false;
228
+ try {
229
+ if (await store.get(runOwnerKey(runId)) !== callerId) return false;
230
+ await store.set(runCancelKey(runId), "1", CANCEL_FLAG_TTL_SECONDS);
231
+ return true;
232
+ } catch {
233
+ return false;
234
+ }
235
+ };
236
+ return {
237
+ begin,
238
+ end,
239
+ get: (runId) => byId.get(runId),
240
+ replay,
241
+ cancel,
242
+ active: () => byKey.size
243
+ };
244
+ };
245
+ //#endregion
246
+ export { createRunGuards, deriveRunKey, runCancelKey };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,27 @@
1
+ import { ActionKvAdapter, ActionKvStore } from '../types';
2
+ export type KvStoreConfig = {
3
+ /**
4
+ * Prefixed onto every key before it reaches the adapter. Defaults to `kv:`.
5
+ *
6
+ * The store a deployment hands over usually holds other things — a page cache, a session index — and a flow
7
+ * writing `visits:home` must not be able to land on one of them.
8
+ */
9
+ prefix?: string;
10
+ };
11
+ /**
12
+ * The `kv` tasks, over whatever a deployment gave them to write into.
13
+ *
14
+ * The adapter is transport: strings in, strings out. Everything a counter's BEHAVIOUR depends on is here, in one
15
+ * place, for every deployment at once — which is the point of the split. Three rules:
16
+ *
17
+ * 1. **Values round-trip through JSON**, so a flow reads back what it wrote instead of its own `toString`. A value
18
+ * written by something else is answered as the string it is rather than failing.
19
+ * 2. **A counter's TTL is set once, by whoever created it, and never extended.** A window refreshed on every hit
20
+ * never closes while traffic keeps arriving — which is a rate limit that stops limiting exactly when it is
21
+ * being leant on. The counter is the one that created it precisely when the increment returns its own amount,
22
+ * so this needs no conditional-expire support from the adapter and works the same on Redis, Memcached or a
23
+ * table.
24
+ * 3. **Nothing is caught.** This is not a cache: a miss means the rate limit did not count and the idempotency key
25
+ * was not seen, so an adapter that cannot answer fails the run rather than being read as "no value".
26
+ */
27
+ export declare const createKvStore: (adapter: ActionKvAdapter, { prefix }?: KvStoreConfig) => ActionKvStore;
@@ -0,0 +1,41 @@
1
+ //#region src/modules/actions/runtime/kvStore.ts
2
+ /**
3
+ * The `kv` tasks, over whatever a deployment gave them to write into.
4
+ *
5
+ * The adapter is transport: strings in, strings out. Everything a counter's BEHAVIOUR depends on is here, in one
6
+ * place, for every deployment at once — which is the point of the split. Three rules:
7
+ *
8
+ * 1. **Values round-trip through JSON**, so a flow reads back what it wrote instead of its own `toString`. A value
9
+ * written by something else is answered as the string it is rather than failing.
10
+ * 2. **A counter's TTL is set once, by whoever created it, and never extended.** A window refreshed on every hit
11
+ * never closes while traffic keeps arriving — which is a rate limit that stops limiting exactly when it is
12
+ * being leant on. The counter is the one that created it precisely when the increment returns its own amount,
13
+ * so this needs no conditional-expire support from the adapter and works the same on Redis, Memcached or a
14
+ * table.
15
+ * 3. **Nothing is caught.** This is not a cache: a miss means the rate limit did not count and the idempotency key
16
+ * was not seen, so an adapter that cannot answer fails the run rather than being read as "no value".
17
+ */
18
+ var createKvStore = (adapter, { prefix = "kv:" } = {}) => {
19
+ const prefixed = (key) => `${prefix}${key}`;
20
+ return {
21
+ get: async (key) => {
22
+ const raw = await adapter.get(prefixed(key));
23
+ if (raw === void 0) return;
24
+ try {
25
+ return JSON.parse(raw);
26
+ } catch {
27
+ return raw;
28
+ }
29
+ },
30
+ set: (key, value, ttlSeconds) => adapter.set(prefixed(key), JSON.stringify(value), ttlSeconds),
31
+ delete: (key) => adapter.delete(prefixed(key)),
32
+ increment: async (key, amount, ttlSeconds) => {
33
+ const full = prefixed(key);
34
+ const value = await adapter.increment(full, amount);
35
+ if (ttlSeconds !== void 0 && value === amount) await adapter.expire(full, ttlSeconds);
36
+ return value;
37
+ }
38
+ };
39
+ };
40
+ //#endregion
41
+ export { createKvStore };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,11 @@
1
+ import { ResolvedActionLimits } from '../types';
2
+ import { ActionLimits } from '@plitzi/sdk-shared';
3
+ export declare const DEFAULT_LIMITS: ResolvedActionLimits;
4
+ /**
5
+ * Merges a document's limits over the deployment's.
6
+ *
7
+ * A per-action value may only ever TIGHTEN: the deployment is the party paying for the connection, the slot and
8
+ * the outbound traffic, and an authored document is customer input. Letting it widen would make every ceiling
9
+ * advisory — which is the same as not having one.
10
+ */
11
+ export declare const resolveLimits: (deployment?: ActionLimits, document?: ActionLimits) => ResolvedActionLimits;
@@ -0,0 +1,35 @@
1
+ //#region src/modules/actions/runtime/limits.ts
2
+ var DEFAULT_LIMITS = {
3
+ timeoutMs: 1e4,
4
+ streamTimeoutMs: 12e4,
5
+ maxNodes: 50,
6
+ maxRequests: 20,
7
+ /** Generous for an API answer and nowhere near what it takes to hurt a process holding several runs at once. */
8
+ maxResponseBytes: 5e6
9
+ };
10
+ var tightest = (deployment, document) => document === void 0 || document <= 0 ? deployment : Math.min(deployment, document);
11
+ /**
12
+ * Merges a document's limits over the deployment's.
13
+ *
14
+ * A per-action value may only ever TIGHTEN: the deployment is the party paying for the connection, the slot and
15
+ * the outbound traffic, and an authored document is customer input. Letting it widen would make every ceiling
16
+ * advisory — which is the same as not having one.
17
+ */
18
+ var resolveLimits = (deployment = {}, document = {}) => {
19
+ const base = {
20
+ timeoutMs: deployment.timeoutMs ?? DEFAULT_LIMITS.timeoutMs,
21
+ streamTimeoutMs: deployment.streamTimeoutMs ?? DEFAULT_LIMITS.streamTimeoutMs,
22
+ maxNodes: deployment.maxNodes ?? DEFAULT_LIMITS.maxNodes,
23
+ maxRequests: deployment.maxRequests ?? DEFAULT_LIMITS.maxRequests,
24
+ maxResponseBytes: deployment.maxResponseBytes ?? DEFAULT_LIMITS.maxResponseBytes
25
+ };
26
+ return {
27
+ timeoutMs: tightest(base.timeoutMs, document.timeoutMs),
28
+ streamTimeoutMs: tightest(base.streamTimeoutMs, document.streamTimeoutMs),
29
+ maxNodes: tightest(base.maxNodes, document.maxNodes),
30
+ maxRequests: tightest(base.maxRequests, document.maxRequests),
31
+ maxResponseBytes: tightest(base.maxResponseBytes, document.maxResponseBytes)
32
+ };
33
+ };
34
+ //#endregion
35
+ export { DEFAULT_LIMITS, resolveLimits };
@@ -0,0 +1,2 @@
1
+ import { ActionKvAdapter } from '../types';
2
+ export declare const createMemoryKv: () => ActionKvAdapter;
@@ -0,0 +1,77 @@
1
+ //#region src/modules/actions/runtime/memoryKv.ts
2
+ /**
3
+ * The `kv` tasks' fallback store: in this process, and nowhere else.
4
+ *
5
+ * Real enough for a single replica and for local work, and deliberately not pretending otherwise — a deployment
6
+ * running several replicas passes an adapter over something shared, because a counter that only counts its own
7
+ * replica is a rate limit that multiplies by the number of them.
8
+ *
9
+ * An adapter like any other: it stores strings and obeys no rules of its own. What a counter DOES lives in
10
+ * `createKvStore`, so the in-process store and a deployment's own behave identically rather than nearly so.
11
+ */
12
+ /** How many writes go by before the map is swept. Cheap enough to be unnoticeable, often enough to bound it. */
13
+ var SWEEP_EVERY = 256;
14
+ var createMemoryKv = () => {
15
+ const entries = /* @__PURE__ */ new Map();
16
+ let writes = 0;
17
+ /**
18
+ * Expiry only ever happened on READ, which is fine for a counter that is read as often as it is written and
19
+ * unbounded for everything else: a rate-limit window keyed by caller, an idempotency key per delivery, anything
20
+ * whose key is used once and never asked for again. Those entries expire and stay, and the map is the process's
21
+ * memory. Redis sweeps its own; this is the same courtesy, amortised over the writes that cause the growth.
22
+ */
23
+ const sweep = () => {
24
+ writes += 1;
25
+ if (writes % SWEEP_EVERY !== 0) return;
26
+ const now = Date.now();
27
+ entries.forEach((entry, key) => {
28
+ if (entry.expiresAt !== void 0 && entry.expiresAt <= now) entries.delete(key);
29
+ });
30
+ };
31
+ const read = (key) => {
32
+ const entry = entries.get(key);
33
+ if (!entry) return;
34
+ if (entry.expiresAt !== void 0 && entry.expiresAt <= Date.now()) {
35
+ entries.delete(key);
36
+ return;
37
+ }
38
+ return entry;
39
+ };
40
+ const expiry = (ttlSeconds) => ttlSeconds === void 0 ? void 0 : Date.now() + ttlSeconds * 1e3;
41
+ return {
42
+ get: (key) => Promise.resolve(read(key)?.value),
43
+ set: (key, value, ttlSeconds) => {
44
+ entries.set(key, {
45
+ value,
46
+ expiresAt: expiry(ttlSeconds)
47
+ });
48
+ sweep();
49
+ return Promise.resolve();
50
+ },
51
+ delete: (key) => {
52
+ entries.delete(key);
53
+ return Promise.resolve();
54
+ },
55
+ increment: (key, amount) => {
56
+ const current = read(key);
57
+ const base = Number(current?.value ?? 0);
58
+ const next = (Number.isFinite(base) ? base : 0) + amount;
59
+ entries.set(key, {
60
+ value: String(next),
61
+ expiresAt: current?.expiresAt
62
+ });
63
+ sweep();
64
+ return Promise.resolve(next);
65
+ },
66
+ expire: (key, ttlSeconds) => {
67
+ const current = read(key);
68
+ if (current) entries.set(key, {
69
+ ...current,
70
+ expiresAt: expiry(ttlSeconds)
71
+ });
72
+ return Promise.resolve();
73
+ }
74
+ };
75
+ };
76
+ //#endregion
77
+ export { createMemoryKv };
@@ -0,0 +1,9 @@
1
+ import { ActionKvStore } from '../types';
2
+ /**
3
+ * Prefixes every key with the space that wrote it.
4
+ *
5
+ * A shared store with unprefixed keys is one space reading — or overwriting — another's counters, which is the
6
+ * cross-tenant leak this whole design exists to avoid. Done at the runner rather than in each task so a
7
+ * deployment's own tasks inherit it too.
8
+ */
9
+ export declare const namespaceKv: (store: ActionKvStore, spaceId: number) => ActionKvStore;
@@ -0,0 +1,19 @@
1
+ //#region src/modules/actions/runtime/namespaceKv.ts
2
+ /**
3
+ * Prefixes every key with the space that wrote it.
4
+ *
5
+ * A shared store with unprefixed keys is one space reading — or overwriting — another's counters, which is the
6
+ * cross-tenant leak this whole design exists to avoid. Done at the runner rather than in each task so a
7
+ * deployment's own tasks inherit it too.
8
+ */
9
+ var namespaceKv = (store, spaceId) => {
10
+ const scoped = (key) => `action:${spaceId}:${key}`;
11
+ return {
12
+ get: (key) => store.get(scoped(key)),
13
+ set: (key, value, ttlSeconds) => store.set(scoped(key), value, ttlSeconds),
14
+ delete: (key) => store.delete(scoped(key)),
15
+ increment: (key, amount, ttlSeconds) => store.increment(scoped(key), amount, ttlSeconds)
16
+ };
17
+ };
18
+ //#endregion
19
+ export { namespaceKv };
@@ -0,0 +1,27 @@
1
+ import { ActionEntry, ActionTriggerType, ElementInteraction, SSRUser } from '@plitzi/sdk-shared';
2
+ export type PrecheckParams = {
3
+ trigger: ActionTriggerType;
4
+ input: Record<string, unknown>;
5
+ user?: SSRUser;
6
+ /** Chain of run ids that caused this one. A run naming its own action is a loop. */
7
+ lineage?: string[];
8
+ };
9
+ /** The entry point that was cleared, and the input that survived its contract. */
10
+ export type PrecheckResult = {
11
+ trigger: ElementInteraction;
12
+ values: Record<string, unknown>;
13
+ };
14
+ /**
15
+ * Everything that decides whether a run may happen at all, before it costs anything.
16
+ *
17
+ * One implementation, two callers, on purpose. The RUNNER calls it because a deployment mounting its own trigger
18
+ * goes straight there, and a check that lived only in the endpoint would be a check a custom trigger silently
19
+ * skips. The ENDPOINT calls it first because these are pure and cheap, and a refusal that has already taken a
20
+ * concurrency slot and a metering event teaches callers to retry harder for something that was never going to run.
21
+ *
22
+ * Everything it reads about the caller — may they, and with what — is on the TRIGGER STEP, so an action reachable
23
+ * both by a signed webhook and by a page call answers each on its own terms instead of on the loosest of the two.
24
+ *
25
+ * Returns the entry point and the coerced input, so neither caller has to find or validate either twice.
26
+ */
27
+ export declare const precheckRun: (entry: ActionEntry, params: PrecheckParams) => PrecheckResult;
@@ -0,0 +1,45 @@
1
+ import { ActionRunError } from "./errors.js";
2
+ import { applyFields } from "./scope.js";
3
+ import { findTriggerNode, triggerParams } from "./triggers.js";
4
+ import { triggerAccess, triggerInput } from "@plitzi/sdk-shared/actions";
5
+ //#region src/modules/actions/runtime/precheck.ts
6
+ var authorize = (access, kind, user) => {
7
+ if (kind === "schedule") return;
8
+ if (!access) throw new ActionRunError("forbidden", "This trigger declares no access rule");
9
+ if (access.mode === "public") return;
10
+ if (!user) throw new ActionRunError("unauthenticated", "This action requires a signed-in visitor");
11
+ if (access.mode === "role") {
12
+ const held = new Set(user.permissions);
13
+ if (access.permissions.some((permission) => !held.has(permission))) throw new ActionRunError("forbidden", "This action requires permissions the caller does not hold");
14
+ }
15
+ };
16
+ /**
17
+ * Everything that decides whether a run may happen at all, before it costs anything.
18
+ *
19
+ * One implementation, two callers, on purpose. The RUNNER calls it because a deployment mounting its own trigger
20
+ * goes straight there, and a check that lived only in the endpoint would be a check a custom trigger silently
21
+ * skips. The ENDPOINT calls it first because these are pure and cheap, and a refusal that has already taken a
22
+ * concurrency slot and a metering event teaches callers to retry harder for something that was never going to run.
23
+ *
24
+ * Everything it reads about the caller — may they, and with what — is on the TRIGGER STEP, so an action reachable
25
+ * both by a signed webhook and by a page call answers each on its own terms instead of on the loosest of the two.
26
+ *
27
+ * Returns the entry point and the coerced input, so neither caller has to find or validate either twice.
28
+ */
29
+ var precheckRun = (entry, params) => {
30
+ const { document } = entry;
31
+ const trigger = findTriggerNode(document.nodes, params.trigger);
32
+ if (!trigger) throw new ActionRunError("forbidden", `This action cannot be started by a "${params.trigger}" trigger`);
33
+ if (!trigger.enabled) throw new ActionRunError("disabled", `This action's "${params.trigger}" trigger is switched off`);
34
+ if (params.lineage?.includes(entry.id)) throw new ActionRunError("recursion", "This action already appears in the run lineage");
35
+ const stepParams = triggerParams(trigger);
36
+ authorize(triggerAccess(stepParams), params.trigger, params.user);
37
+ const { values, missing, invalid } = applyFields(triggerInput(stepParams), params.input);
38
+ if (missing.length > 0 || invalid.length > 0) throw new ActionRunError("invalid_input", `Invalid input: ${[...missing, ...invalid].join(", ")}`);
39
+ return {
40
+ trigger,
41
+ values
42
+ };
43
+ };
44
+ //#endregion
45
+ export { precheckRun };
@@ -0,0 +1,15 @@
1
+ import { RscElementResolver } from '../../rsc/resolveRscData';
2
+ import { ActionsModule } from '../index';
3
+ import { ActionLookups } from '../types';
4
+ /**
5
+ * Resolves a `runtime: 'server'` element that names an ACTION rather than a connector.
6
+ *
7
+ * This is the read a manifest cannot express: two calls that have to be joined, a field computed from both, a
8
+ * shape that depends on who is looking. The element names an action exactly as it would name a connector, and the
9
+ * page still learns nothing about what happens on the other side.
10
+ *
11
+ * Its input is the page's own context — route params, then query params — plus whatever the element declares, so
12
+ * an action feeding `/blog/:slug` reads `{{ input.slug }}` and needs nothing else. The action's own input contract
13
+ * still drops everything it did not declare.
14
+ */
15
+ export declare const createActionResolver: (lookups: ActionLookups, module: ActionsModule) => RscElementResolver;