velocious 1.0.571 → 1.0.573

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 (80) hide show
  1. package/README.md +2 -0
  2. package/build/background-jobs/web/authorization.js +2 -33
  3. package/build/background-jobs/web/path-matcher.js +5 -30
  4. package/build/database/drivers/base.js +17 -0
  5. package/build/database/drivers/mssql/index.js +30 -0
  6. package/build/database/record/index.js +22 -10
  7. package/build/deployment-api/controller.js +437 -0
  8. package/build/deployment-api/index.js +210 -0
  9. package/build/deployment-api/path-matcher.js +45 -0
  10. package/build/deployment-api/registry.js +84 -0
  11. package/build/deployment-api/run-store.js +798 -0
  12. package/build/deployment-api/sanitize.js +114 -0
  13. package/build/http-client/request.js +3 -1
  14. package/build/src/background-jobs/web/authorization.d.ts.map +1 -1
  15. package/build/src/background-jobs/web/authorization.js +3 -29
  16. package/build/src/background-jobs/web/path-matcher.d.ts +2 -12
  17. package/build/src/background-jobs/web/path-matcher.d.ts.map +1 -1
  18. package/build/src/background-jobs/web/path-matcher.js +5 -30
  19. package/build/src/database/drivers/base.d.ts +16 -0
  20. package/build/src/database/drivers/base.d.ts.map +1 -1
  21. package/build/src/database/drivers/base.js +16 -1
  22. package/build/src/database/drivers/mssql/index.d.ts.map +1 -1
  23. package/build/src/database/drivers/mssql/index.js +29 -1
  24. package/build/src/database/record/index.d.ts +3 -2
  25. package/build/src/database/record/index.d.ts.map +1 -1
  26. package/build/src/database/record/index.js +21 -10
  27. package/build/src/deployment-api/controller.d.ts +117 -0
  28. package/build/src/deployment-api/controller.d.ts.map +1 -0
  29. package/build/src/deployment-api/controller.js +384 -0
  30. package/build/src/deployment-api/index.d.ts +46 -0
  31. package/build/src/deployment-api/index.d.ts.map +1 -0
  32. package/build/src/deployment-api/index.js +178 -0
  33. package/build/src/deployment-api/path-matcher.d.ts +31 -0
  34. package/build/src/deployment-api/path-matcher.d.ts.map +1 -0
  35. package/build/src/deployment-api/path-matcher.js +39 -0
  36. package/build/src/deployment-api/registry.d.ts +106 -0
  37. package/build/src/deployment-api/registry.d.ts.map +1 -0
  38. package/build/src/deployment-api/registry.js +74 -0
  39. package/build/src/deployment-api/run-store.d.ts +402 -0
  40. package/build/src/deployment-api/run-store.d.ts.map +1 -0
  41. package/build/src/deployment-api/run-store.js +711 -0
  42. package/build/src/deployment-api/sanitize.d.ts +27 -0
  43. package/build/src/deployment-api/sanitize.d.ts.map +1 -0
  44. package/build/src/deployment-api/sanitize.js +100 -0
  45. package/build/src/http-client/request.d.ts.map +1 -1
  46. package/build/src/http-client/request.js +4 -2
  47. package/build/src/sync/signed-sync-envelope-replay-service.d.ts +154 -0
  48. package/build/src/sync/signed-sync-envelope-replay-service.d.ts.map +1 -0
  49. package/build/src/sync/signed-sync-envelope-replay-service.js +294 -0
  50. package/build/src/sync/sync-envelope-replay-service.d.ts +84 -15
  51. package/build/src/sync/sync-envelope-replay-service.d.ts.map +1 -1
  52. package/build/src/sync/sync-envelope-replay-service.js +98 -16
  53. package/build/src/utils/bearer-token.d.ts +15 -0
  54. package/build/src/utils/bearer-token.d.ts.map +1 -0
  55. package/build/src/utils/bearer-token.js +29 -0
  56. package/build/src/utils/mount-prefix.d.ts +21 -0
  57. package/build/src/utils/mount-prefix.d.ts.map +1 -0
  58. package/build/src/utils/mount-prefix.js +35 -0
  59. package/build/sync/signed-sync-envelope-replay-service.js +342 -0
  60. package/build/sync/sync-envelope-replay-service.js +110 -15
  61. package/build/tsconfig.tsbuildinfo +1 -1
  62. package/build/utils/bearer-token.js +34 -0
  63. package/build/utils/mount-prefix.js +36 -0
  64. package/package.json +2 -1
  65. package/src/background-jobs/web/authorization.js +2 -33
  66. package/src/background-jobs/web/path-matcher.js +5 -30
  67. package/src/database/drivers/base.js +17 -0
  68. package/src/database/drivers/mssql/index.js +30 -0
  69. package/src/database/record/index.js +22 -10
  70. package/src/deployment-api/controller.js +437 -0
  71. package/src/deployment-api/index.js +210 -0
  72. package/src/deployment-api/path-matcher.js +45 -0
  73. package/src/deployment-api/registry.js +84 -0
  74. package/src/deployment-api/run-store.js +798 -0
  75. package/src/deployment-api/sanitize.js +114 -0
  76. package/src/http-client/request.js +3 -1
  77. package/src/sync/signed-sync-envelope-replay-service.js +342 -0
  78. package/src/sync/sync-envelope-replay-service.js +110 -15
  79. package/src/utils/bearer-token.js +34 -0
  80. package/src/utils/mount-prefix.js +36 -0
@@ -0,0 +1,29 @@
1
+ // @ts-check
2
+ import crypto from "node:crypto";
3
+ /**
4
+ * Constant-time comparison so token checks don't leak length/contents through
5
+ * timing. Returns false for differing lengths before the timing-safe compare.
6
+ * @param {string} a - First value.
7
+ * @param {string} b - Second value.
8
+ * @returns {boolean} - Whether the values are equal.
9
+ */
10
+ export function constantTimeEqual(a, b) {
11
+ const bufferA = Buffer.from(String(a));
12
+ const bufferB = Buffer.from(String(b));
13
+ if (bufferA.length !== bufferB.length)
14
+ return false;
15
+ return crypto.timingSafeEqual(bufferA, bufferB);
16
+ }
17
+ /**
18
+ * Extracts the bearer token from the Authorization header of a request.
19
+ * @param {import("../http-server/client/request.js").default | import("../http-server/client/websocket-request.js").default} request - Request object.
20
+ * @returns {string | null} - Bearer token from the Authorization header, if any.
21
+ */
22
+ export function bearerToken(request) {
23
+ const header = request.header("authorization");
24
+ if (typeof header !== "string")
25
+ return null;
26
+ const match = header.match(/^Bearer\s+(.+)$/i);
27
+ return match ? match[1].trim() : null;
28
+ }
29
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiYmVhcmVyLXRva2VuLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vLi4vc3JjL3V0aWxzL2JlYXJlci10b2tlbi5qcyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxZQUFZO0FBRVosT0FBTyxNQUFNLE1BQU0sYUFBYSxDQUFBO0FBRWhDOzs7Ozs7R0FNRztBQUNILE1BQU0sVUFBVSxpQkFBaUIsQ0FBQyxDQUFDLEVBQUUsQ0FBQztJQUNwQyxNQUFNLE9BQU8sR0FBRyxNQUFNLENBQUMsSUFBSSxDQUFDLE1BQU0sQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFBO0lBQ3RDLE1BQU0sT0FBTyxHQUFHLE1BQU0sQ0FBQyxJQUFJLENBQUMsTUFBTSxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUE7SUFFdEMsSUFBSSxPQUFPLENBQUMsTUFBTSxLQUFLLE9BQU8sQ0FBQyxNQUFNO1FBQUUsT0FBTyxLQUFLLENBQUE7SUFFbkQsT0FBTyxNQUFNLENBQUMsZUFBZSxDQUFDLE9BQU8sRUFBRSxPQUFPLENBQUMsQ0FBQTtBQUNqRCxDQUFDO0FBRUQ7Ozs7R0FJRztBQUNILE1BQU0sVUFBVSxXQUFXLENBQUMsT0FBTztJQUNqQyxNQUFNLE1BQU0sR0FBRyxPQUFPLENBQUMsTUFBTSxDQUFDLGVBQWUsQ0FBQyxDQUFBO0lBRTlDLElBQUksT0FBTyxNQUFNLEtBQUssUUFBUTtRQUFFLE9BQU8sSUFBSSxDQUFBO0lBRTNDLE1BQU0sS0FBSyxHQUFHLE1BQU0sQ0FBQyxLQUFLLENBQUMsa0JBQWtCLENBQUMsQ0FBQTtJQUU5QyxPQUFPLEtBQUssQ0FBQyxDQUFDLENBQUMsS0FBSyxDQUFDLENBQUMsQ0FBQyxDQUFDLElBQUksRUFBRSxDQUFDLENBQUMsQ0FBQyxJQUFJLENBQUE7QUFDdkMsQ0FBQyIsInNvdXJjZXNDb250ZW50IjpbIi8vIEB0cy1jaGVja1xuXG5pbXBvcnQgY3J5cHRvIGZyb20gXCJub2RlOmNyeXB0b1wiXG5cbi8qKlxuICogQ29uc3RhbnQtdGltZSBjb21wYXJpc29uIHNvIHRva2VuIGNoZWNrcyBkb24ndCBsZWFrIGxlbmd0aC9jb250ZW50cyB0aHJvdWdoXG4gKiB0aW1pbmcuIFJldHVybnMgZmFsc2UgZm9yIGRpZmZlcmluZyBsZW5ndGhzIGJlZm9yZSB0aGUgdGltaW5nLXNhZmUgY29tcGFyZS5cbiAqIEBwYXJhbSB7c3RyaW5nfSBhIC0gRmlyc3QgdmFsdWUuXG4gKiBAcGFyYW0ge3N0cmluZ30gYiAtIFNlY29uZCB2YWx1ZS5cbiAqIEByZXR1cm5zIHtib29sZWFufSAtIFdoZXRoZXIgdGhlIHZhbHVlcyBhcmUgZXF1YWwuXG4gKi9cbmV4cG9ydCBmdW5jdGlvbiBjb25zdGFudFRpbWVFcXVhbChhLCBiKSB7XG4gIGNvbnN0IGJ1ZmZlckEgPSBCdWZmZXIuZnJvbShTdHJpbmcoYSkpXG4gIGNvbnN0IGJ1ZmZlckIgPSBCdWZmZXIuZnJvbShTdHJpbmcoYikpXG5cbiAgaWYgKGJ1ZmZlckEubGVuZ3RoICE9PSBidWZmZXJCLmxlbmd0aCkgcmV0dXJuIGZhbHNlXG5cbiAgcmV0dXJuIGNyeXB0by50aW1pbmdTYWZlRXF1YWwoYnVmZmVyQSwgYnVmZmVyQilcbn1cblxuLyoqXG4gKiBFeHRyYWN0cyB0aGUgYmVhcmVyIHRva2VuIGZyb20gdGhlIEF1dGhvcml6YXRpb24gaGVhZGVyIG9mIGEgcmVxdWVzdC5cbiAqIEBwYXJhbSB7aW1wb3J0KFwiLi4vaHR0cC1zZXJ2ZXIvY2xpZW50L3JlcXVlc3QuanNcIikuZGVmYXVsdCB8IGltcG9ydChcIi4uL2h0dHAtc2VydmVyL2NsaWVudC93ZWJzb2NrZXQtcmVxdWVzdC5qc1wiKS5kZWZhdWx0fSByZXF1ZXN0IC0gUmVxdWVzdCBvYmplY3QuXG4gKiBAcmV0dXJucyB7c3RyaW5nIHwgbnVsbH0gLSBCZWFyZXIgdG9rZW4gZnJvbSB0aGUgQXV0aG9yaXphdGlvbiBoZWFkZXIsIGlmIGFueS5cbiAqL1xuZXhwb3J0IGZ1bmN0aW9uIGJlYXJlclRva2VuKHJlcXVlc3QpIHtcbiAgY29uc3QgaGVhZGVyID0gcmVxdWVzdC5oZWFkZXIoXCJhdXRob3JpemF0aW9uXCIpXG5cbiAgaWYgKHR5cGVvZiBoZWFkZXIgIT09IFwic3RyaW5nXCIpIHJldHVybiBudWxsXG5cbiAgY29uc3QgbWF0Y2ggPSBoZWFkZXIubWF0Y2goL15CZWFyZXJcXHMrKC4rKSQvaSlcblxuICByZXR1cm4gbWF0Y2ggPyBtYXRjaFsxXS50cmltKCkgOiBudWxsXG59XG4iXX0=
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Normalizes a mount prefix: ensures a leading slash and strips any trailing
3
+ * slash so `/velocious/jobs/` and `/velocious/jobs` behave identically.
4
+ * @param {string} at - Raw mount prefix.
5
+ * @returns {string} - Normalized prefix.
6
+ */
7
+ export function normalizeMountPrefix(at: string): string;
8
+ /**
9
+ * Extracts the request sub-path under a normalized mount prefix, or null when
10
+ * the path is outside the mount. A root mount (`/`) treats the whole path as
11
+ * the sub-path.
12
+ * @param {object} args - Options.
13
+ * @param {string} args.prefix - Normalized mount prefix.
14
+ * @param {string} args.path - Request path without query string.
15
+ * @returns {string | null} - Sub-path ("/" for the bare prefix) or null.
16
+ */
17
+ export function mountSubPath({ prefix, path }: {
18
+ prefix: string;
19
+ path: string;
20
+ }): string | null;
21
+ //# sourceMappingURL=mount-prefix.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mount-prefix.d.ts","sourceRoot":"","sources":["../../../src/utils/mount-prefix.js"],"names":[],"mappings":"AAEA;;;;;GAKG;AACH,yCAHW,MAAM,GACJ,MAAM,CAYlB;AAED;;;;;;;;GAQG;AACH,+CAJG;IAAqB,MAAM,EAAnB,MAAM;IACO,IAAI,EAAjB,MAAM;CACd,GAAU,MAAM,GAAG,IAAI,CAQzB"}
@@ -0,0 +1,35 @@
1
+ // @ts-check
2
+ /**
3
+ * Normalizes a mount prefix: ensures a leading slash and strips any trailing
4
+ * slash so `/velocious/jobs/` and `/velocious/jobs` behave identically.
5
+ * @param {string} at - Raw mount prefix.
6
+ * @returns {string} - Normalized prefix.
7
+ */
8
+ export function normalizeMountPrefix(at) {
9
+ if (typeof at !== "string" || !at.startsWith("/")) {
10
+ throw new Error(`mount requires an 'at' path starting with '/', got: ${String(at)}`);
11
+ }
12
+ if (at.length > 1 && at.endsWith("/")) {
13
+ return at.slice(0, -1);
14
+ }
15
+ return at;
16
+ }
17
+ /**
18
+ * Extracts the request sub-path under a normalized mount prefix, or null when
19
+ * the path is outside the mount. A root mount (`/`) treats the whole path as
20
+ * the sub-path.
21
+ * @param {object} args - Options.
22
+ * @param {string} args.prefix - Normalized mount prefix.
23
+ * @param {string} args.path - Request path without query string.
24
+ * @returns {string | null} - Sub-path ("/" for the bare prefix) or null.
25
+ */
26
+ export function mountSubPath({ prefix, path }) {
27
+ if (prefix === "/")
28
+ return path;
29
+ if (path === prefix)
30
+ return "/";
31
+ if (path.startsWith(`${prefix}/`))
32
+ return path.slice(prefix.length);
33
+ return null;
34
+ }
35
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoibW91bnQtcHJlZml4LmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vLi4vc3JjL3V0aWxzL21vdW50LXByZWZpeC5qcyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxZQUFZO0FBRVo7Ozs7O0dBS0c7QUFDSCxNQUFNLFVBQVUsb0JBQW9CLENBQUMsRUFBRTtJQUNyQyxJQUFJLE9BQU8sRUFBRSxLQUFLLFFBQVEsSUFBSSxDQUFDLEVBQUUsQ0FBQyxVQUFVLENBQUMsR0FBRyxDQUFDLEVBQUUsQ0FBQztRQUNsRCxNQUFNLElBQUksS0FBSyxDQUFDLHVEQUF1RCxNQUFNLENBQUMsRUFBRSxDQUFDLEVBQUUsQ0FBQyxDQUFBO0lBQ3RGLENBQUM7SUFFRCxJQUFJLEVBQUUsQ0FBQyxNQUFNLEdBQUcsQ0FBQyxJQUFJLEVBQUUsQ0FBQyxRQUFRLENBQUMsR0FBRyxDQUFDLEVBQUUsQ0FBQztRQUN0QyxPQUFPLEVBQUUsQ0FBQyxLQUFLLENBQUMsQ0FBQyxFQUFFLENBQUMsQ0FBQyxDQUFDLENBQUE7SUFDeEIsQ0FBQztJQUVELE9BQU8sRUFBRSxDQUFBO0FBQ1gsQ0FBQztBQUVEOzs7Ozs7OztHQVFHO0FBQ0gsTUFBTSxVQUFVLFlBQVksQ0FBQyxFQUFDLE1BQU0sRUFBRSxJQUFJLEVBQUM7SUFDekMsSUFBSSxNQUFNLEtBQUssR0FBRztRQUFFLE9BQU8sSUFBSSxDQUFBO0lBQy9CLElBQUksSUFBSSxLQUFLLE1BQU07UUFBRSxPQUFPLEdBQUcsQ0FBQTtJQUMvQixJQUFJLElBQUksQ0FBQyxVQUFVLENBQUMsR0FBRyxNQUFNLEdBQUcsQ0FBQztRQUFFLE9BQU8sSUFBSSxDQUFDLEtBQUssQ0FBQyxNQUFNLENBQUMsTUFBTSxDQUFDLENBQUE7SUFFbkUsT0FBTyxJQUFJLENBQUE7QUFDYixDQUFDIiwic291cmNlc0NvbnRlbnQiOlsiLy8gQHRzLWNoZWNrXG5cbi8qKlxuICogTm9ybWFsaXplcyBhIG1vdW50IHByZWZpeDogZW5zdXJlcyBhIGxlYWRpbmcgc2xhc2ggYW5kIHN0cmlwcyBhbnkgdHJhaWxpbmdcbiAqIHNsYXNoIHNvIGAvdmVsb2Npb3VzL2pvYnMvYCBhbmQgYC92ZWxvY2lvdXMvam9ic2AgYmVoYXZlIGlkZW50aWNhbGx5LlxuICogQHBhcmFtIHtzdHJpbmd9IGF0IC0gUmF3IG1vdW50IHByZWZpeC5cbiAqIEByZXR1cm5zIHtzdHJpbmd9IC0gTm9ybWFsaXplZCBwcmVmaXguXG4gKi9cbmV4cG9ydCBmdW5jdGlvbiBub3JtYWxpemVNb3VudFByZWZpeChhdCkge1xuICBpZiAodHlwZW9mIGF0ICE9PSBcInN0cmluZ1wiIHx8ICFhdC5zdGFydHNXaXRoKFwiL1wiKSkge1xuICAgIHRocm93IG5ldyBFcnJvcihgbW91bnQgcmVxdWlyZXMgYW4gJ2F0JyBwYXRoIHN0YXJ0aW5nIHdpdGggJy8nLCBnb3Q6ICR7U3RyaW5nKGF0KX1gKVxuICB9XG5cbiAgaWYgKGF0Lmxlbmd0aCA+IDEgJiYgYXQuZW5kc1dpdGgoXCIvXCIpKSB7XG4gICAgcmV0dXJuIGF0LnNsaWNlKDAsIC0xKVxuICB9XG5cbiAgcmV0dXJuIGF0XG59XG5cbi8qKlxuICogRXh0cmFjdHMgdGhlIHJlcXVlc3Qgc3ViLXBhdGggdW5kZXIgYSBub3JtYWxpemVkIG1vdW50IHByZWZpeCwgb3IgbnVsbCB3aGVuXG4gKiB0aGUgcGF0aCBpcyBvdXRzaWRlIHRoZSBtb3VudC4gQSByb290IG1vdW50IChgL2ApIHRyZWF0cyB0aGUgd2hvbGUgcGF0aCBhc1xuICogdGhlIHN1Yi1wYXRoLlxuICogQHBhcmFtIHtvYmplY3R9IGFyZ3MgLSBPcHRpb25zLlxuICogQHBhcmFtIHtzdHJpbmd9IGFyZ3MucHJlZml4IC0gTm9ybWFsaXplZCBtb3VudCBwcmVmaXguXG4gKiBAcGFyYW0ge3N0cmluZ30gYXJncy5wYXRoIC0gUmVxdWVzdCBwYXRoIHdpdGhvdXQgcXVlcnkgc3RyaW5nLlxuICogQHJldHVybnMge3N0cmluZyB8IG51bGx9IC0gU3ViLXBhdGggKFwiL1wiIGZvciB0aGUgYmFyZSBwcmVmaXgpIG9yIG51bGwuXG4gKi9cbmV4cG9ydCBmdW5jdGlvbiBtb3VudFN1YlBhdGgoe3ByZWZpeCwgcGF0aH0pIHtcbiAgaWYgKHByZWZpeCA9PT0gXCIvXCIpIHJldHVybiBwYXRoXG4gIGlmIChwYXRoID09PSBwcmVmaXgpIHJldHVybiBcIi9cIlxuICBpZiAocGF0aC5zdGFydHNXaXRoKGAke3ByZWZpeH0vYCkpIHJldHVybiBwYXRoLnNsaWNlKHByZWZpeC5sZW5ndGgpXG5cbiAgcmV0dXJuIG51bGxcbn1cbiJdfQ==
@@ -0,0 +1,342 @@
1
+ // @ts-check
2
+
3
+ import SyncEnvelopeReplayService from "./sync-envelope-replay-service.js"
4
+ import {frontendModelSyncManifestForBackendProjects} from "../frontend-models/resource-definition.js"
5
+ import {verifyOfflineGrant} from "./offline-grant.js"
6
+ import {mutationIdempotencyKey, verifySignedMutation} from "./device-identity.js"
7
+ import VelociousError from "../velocious-error.js"
8
+
9
+ /**
10
+ * @typedef {import("./device-identity.js").SignedSyncMutation} SignedSyncMutation
11
+ * @typedef {import("./offline-grant.js").SignedOfflineGrant} SignedOfflineGrant
12
+ */
13
+
14
+ /**
15
+ * Sync-envelope replay service that authenticates via backend-signed device
16
+ * certificates and signed offline grants instead of online session tokens.
17
+ *
18
+ * This is the generic Velocious primitive for long-offline and peer-forwarded
19
+ * mutations: the HTTP uploader may differ from the mutation actor, but replay
20
+ * authority is derived from the signed envelope and grant, not the uploader's
21
+ * session.
22
+ *
23
+ * Every mutation is validated against the current sync manifest (the same
24
+ * contract as the controller's sync replay endpoint): the model and operation
25
+ * must be enabled in the current manifest, the grant resource entry must be
26
+ * enabled and list the operation, and the grant policy hash must equal both the
27
+ * mutation policy hash and the current manifest policy hash. Routed resources
28
+ * are authorized through an actor/grant-scoped ability built by the configured
29
+ * `abilityFactory`; without one, routed signed replay fails closed.
30
+ */
31
+ export default class SignedSyncEnvelopeReplayService extends SyncEnvelopeReplayService {
32
+ /**
33
+ * Creates a signed sync-envelope replay service.
34
+ * @param {object} args - Constructor arguments.
35
+ * @param {import("./device-identity.js").SyncJsonWebKey} args.backendPublicKey - Backend public key used to verify device certificates.
36
+ * @param {Array<import("./offline-grant.js").OfflineGrantSigningKey>} [args.offlineGrantSigningKeys] - Offline-grant verification keys.
37
+ * @param {(userId: string) => Promise<{id: () => string} | null>} [args.actorLookup] - Optional lookup from grant user id to an actor object with an `id()` method. Defaults to a wrapper around the grant user id.
38
+ * @param {(args: {actor: ?, configuration: import("../configuration.js").default | null, grant: import("./offline-grant.js").OfflineGrant}) => Promise<import("../authorization/ability.js").default | null> | import("../authorization/ability.js").default | null} [args.abilityFactory] - Builds the actor/grant-scoped ability used to authorize routed resources. Required for routed signed replay; without it every routed sync fails closed.
39
+ * @param {Record<string, ?>} [args.rest] - Remaining arguments forwarded to {@link SyncEnvelopeReplayService}.
40
+ */
41
+ constructor({abilityFactory, backendPublicKey, offlineGrantSigningKeys, actorLookup, ...rest}) {
42
+ super({
43
+ actorForeignKeyColumn: "authenticationTokenId",
44
+ ...rest
45
+ })
46
+
47
+ if (!backendPublicKey) throw new Error("SignedSyncEnvelopeReplayService requires backendPublicKey")
48
+
49
+ this.abilityFactory = abilityFactory || null
50
+ this.backendPublicKey = backendPublicKey
51
+ this.offlineGrantSigningKeys = offlineGrantSigningKeys || []
52
+ this.actorLookup = actorLookup || null
53
+ }
54
+
55
+ /**
56
+ * Verifies signed mutations and then runs the generic replay loop over the
57
+ * derived sync envelopes. Verified actor, grant, and derived syncs are kept
58
+ * in the request-local `requestState` object so concurrent replay calls on
59
+ * one service instance cannot cross their authentication state.
60
+ * @param {Record<string, ?>} params - Request params.
61
+ * @param {Record<string, ?>} [requestState] - Request-local state shared with the base replay hooks.
62
+ * @returns {Promise<{syncs: Array<Record<string, ?>>}>} Replay response.
63
+ */
64
+ async replay(params, requestState = {}) {
65
+ const verified = await this.verifyAndTransformSignedReplay(params)
66
+
67
+ requestState.signedReplayActor = verified.actor
68
+ requestState.signedReplayGrant = verified.grant
69
+ requestState.signedReplaySyncs = verified.syncs
70
+
71
+ const result = await super.replay(params, requestState)
72
+
73
+ return {
74
+ syncs: result.syncs.map((syncResponse, index) => {
75
+ const {id: _id, ...rest} = syncResponse
76
+ const idempotencyKey = verified.syncs[index]?.idempotencyKey
77
+
78
+ return idempotencyKey ? {...rest, idempotencyKey} : syncResponse
79
+ })
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Returns the verified actor prepared during {@link SignedSyncEnvelopeReplayService#replay}.
85
+ * @param {Record<string, ?>} _params - Request params.
86
+ * @param {Record<string, ?>} requestState - Request-local state.
87
+ * @returns {Promise<{authenticated: true, actor: ?} | {authenticated: false, errorCode: string, errorMessage: string}>} Auth result.
88
+ */
89
+ async authenticateReplay(_params, requestState) {
90
+ const actor = requestState?.signedReplayActor
91
+
92
+ if (!actor) {
93
+ return {
94
+ authenticated: false,
95
+ errorCode: "missing-signed-replay",
96
+ errorMessage: "Expected a pre-verified signed replay"
97
+ }
98
+ }
99
+
100
+ return {actor, authenticated: true}
101
+ }
102
+
103
+ /**
104
+ * Returns the sync envelopes derived from the verified signed mutations.
105
+ * @param {Record<string, ?>} _params - Request params.
106
+ * @param {Record<string, ?>} requestState - Request-local state.
107
+ * @returns {Array<Record<string, ?>>} Sync envelopes.
108
+ */
109
+ replaySyncs(_params, requestState) {
110
+ return requestState?.signedReplaySyncs || []
111
+ }
112
+
113
+ /**
114
+ * Builds the replay context carrying the verified signed actor and grant
115
+ * (with its scopes) plus the offline runtime marker, so resource hooks and
116
+ * ability factories authorize against the signer instead of the uploader.
117
+ * @param {{actor: ?, params: Record<string, ?>, requestState: Record<string, ?>}} args - Actor, request params, and request-local state.
118
+ * @returns {Promise<Record<string, ?>>} Replay context.
119
+ */
120
+ async buildReplayContext({actor, requestState}) {
121
+ const grant = /** @type {import("./offline-grant.js").OfflineGrant | undefined} */ (requestState?.signedReplayGrant)
122
+
123
+ return {
124
+ currentUser: actor,
125
+ offlineGrant: grant,
126
+ offlineGrantScopes: grant?.scopes || {},
127
+ resourceRuntime: "offline"
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Derives the routed-resource ability from the verified signed actor and
133
+ * grant through the configured `abilityFactory`. The constructor-wide
134
+ * uploader ability is never used for signed replay: without a factory (or a
135
+ * factory result) every routed sync fails closed with a client-safe error.
136
+ * @param {{actor: ?, context: Record<string, ?>}} args - Verified actor and replay context.
137
+ * @returns {Promise<{ability: import("../authorization/ability.js").default, abilityContext: Record<string, ?>}>} Scoped ability and resource context.
138
+ */
139
+ async replayAbilityFor({actor, context}) {
140
+ const grant = /** @type {import("./offline-grant.js").OfflineGrant} */ (context.offlineGrant)
141
+ const ability = this.abilityFactory
142
+ ? await this.abilityFactory({actor, configuration: this.configuration, grant})
143
+ : null
144
+
145
+ if (!ability) {
146
+ throw VelociousError.safe("Signed sync replay requires an actor/grant-scoped abilityFactory.", {code: "signed-replay-ability-missing"})
147
+ }
148
+
149
+ return {ability, abilityContext: context}
150
+ }
151
+
152
+ /**
153
+ * Verifies every signed mutation, its offline grant, and the actor/grant
154
+ * consistency, then transforms the envelopes into the sync format the base
155
+ * replay service understands.
156
+ * @param {Record<string, ?>} params - Request params.
157
+ * @returns {Promise<{actor: ?, grant: import("./offline-grant.js").OfflineGrant, syncs: Array<Record<string, ?>>}>} Verified actor, common grant, and derived syncs.
158
+ */
159
+ async verifyAndTransformSignedReplay(params) {
160
+ const rawEntries = Array.isArray(params.signedMutations) ? params.signedMutations : []
161
+
162
+ if (rawEntries.length === 0) {
163
+ throw VelociousError.safe("Expected signed mutations.", {code: "missing-signed-mutations"})
164
+ }
165
+
166
+ const syncManifest = this.configuration
167
+ ? frontendModelSyncManifestForBackendProjects(this.configuration.getBackendProjects())
168
+ : {}
169
+
170
+ /** @type {string | null} */
171
+ let grantUserId = null
172
+ /** @type {string | null} */
173
+ let grantDeviceId = null
174
+ /** @type {string | null} */
175
+ let grantId = null
176
+ /** @type {import("./offline-grant.js").OfflineGrant | null} */
177
+ let commonGrant = null
178
+ /** @type {Array<Record<string, ?>>} */
179
+ const syncs = []
180
+
181
+ for (const rawEntry of rawEntries) {
182
+ if (!rawEntry || typeof rawEntry !== "object") {
183
+ throw VelociousError.safe("Invalid signed mutation entry.", {code: "invalid-signed-mutation-entry"})
184
+ }
185
+
186
+ const signedMutation = /** @type {SignedSyncMutation} */ (rawEntry.signedMutation)
187
+ const signedOfflineGrant = /** @type {SignedOfflineGrant} */ (rawEntry.signedOfflineGrant)
188
+
189
+ if (!signedMutation || !signedOfflineGrant) {
190
+ throw VelociousError.safe("Expected signed mutation and signed offline grant.", {code: "incomplete-signed-mutation"})
191
+ }
192
+
193
+ const mutation = await verifySignedMutation({
194
+ backendPublicKey: this.backendPublicKey,
195
+ signedMutation
196
+ })
197
+ const offlineGrant = await verifyOfflineGrant({
198
+ signedGrant: signedOfflineGrant,
199
+ signingKeys: this.offlineGrantSigningKeys
200
+ })
201
+
202
+ if (mutation.actorUserId !== offlineGrant.userId) {
203
+ throw VelociousError.safe("Mutation actor does not match offline grant.", {code: "actor-grant-mismatch"})
204
+ }
205
+
206
+ if (mutation.actorDeviceId !== offlineGrant.deviceId) {
207
+ throw VelociousError.safe("Mutation device does not match offline grant.", {code: "device-grant-mismatch"})
208
+ }
209
+
210
+ if (mutation.offlineGrantId !== offlineGrant.grantId) {
211
+ throw VelociousError.safe("Mutation grant id does not match offline grant.", {code: "grant-id-mismatch"})
212
+ }
213
+
214
+ if (grantUserId === null) {
215
+ grantUserId = offlineGrant.userId
216
+ grantDeviceId = offlineGrant.deviceId
217
+ grantId = offlineGrant.grantId
218
+ commonGrant = offlineGrant
219
+ } else if (
220
+ grantUserId !== offlineGrant.userId ||
221
+ grantDeviceId !== offlineGrant.deviceId ||
222
+ grantId !== offlineGrant.grantId
223
+ ) {
224
+ throw VelociousError.safe("All signed mutations in a batch must share actor, device, and grant.", {code: "mixed-signed-replay-batch"})
225
+ }
226
+
227
+ this.validateCurrentSyncPolicy({mutation, syncManifest})
228
+ this.validateGrantAgainstSyncPolicy({mutation, offlineGrant, syncManifest})
229
+
230
+ const sync = this.syncFromSignedMutation({mutation})
231
+ const idempotencyKey = mutationIdempotencyKey({mutation})
232
+
233
+ syncs.push({...sync, id: idempotencyKey, idempotencyKey})
234
+ }
235
+
236
+ if (!grantUserId || !commonGrant) {
237
+ throw VelociousError.safe("Could not resolve signed replay actor.", {code: "missing-signed-replay-actor"})
238
+ }
239
+
240
+ if (this.actorLookup) {
241
+ const lookedUpActor = await this.actorLookup(grantUserId)
242
+
243
+ if (!lookedUpActor) {
244
+ throw VelociousError.safe("Signed replay actor not found.", {code: "signed-replay-actor-missing"})
245
+ }
246
+
247
+ return {actor: lookedUpActor, grant: commonGrant, syncs}
248
+ }
249
+
250
+ return {actor: {id: () => grantUserId}, grant: commonGrant, syncs}
251
+ }
252
+
253
+ /**
254
+ * Validates a mutation against the current sync manifest: the model and the
255
+ * operation must be enabled, and the mutation's policy hash must match the
256
+ * current manifest policy hash.
257
+ * @param {{mutation: import("./device-identity.js").SyncMutation, syncManifest: Record<string, import("../configuration-types.js").NormalizedFrontendModelResourceSyncConfiguration>}} args - Validation args.
258
+ * @returns {void} Throws a client-safe error when the current policy denies the mutation.
259
+ */
260
+ validateCurrentSyncPolicy({mutation, syncManifest}) {
261
+ const syncResource = syncManifest[mutation.model]
262
+
263
+ if (!syncResource) {
264
+ throw VelociousError.safe(`Sync replay model is not enabled: ${mutation.model}.`, {code: "sync-replay-model-not-enabled"})
265
+ }
266
+
267
+ if (!syncResource.operations.includes(mutation.operation)) {
268
+ throw VelociousError.safe(`Sync replay operation is not enabled for ${mutation.model}: ${mutation.operation}.`, {code: "sync-replay-operation-not-enabled"})
269
+ }
270
+
271
+ if (syncResource.policyHash !== mutation.policyHash) {
272
+ throw VelociousError.safe(`Sync replay policy hash mismatch for ${mutation.model}.`, {code: "sync-replay-policy-hash-mismatch"})
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Validates the verified offline grant against the current sync policy, the
278
+ * same contract as the controller's sync replay endpoint: the grant resource
279
+ * entry must be a normalized manifest entry (enabled with an operations list
280
+ * and the current policy hash), it must list the mutation operation, and its
281
+ * policy hash must equal both the mutation and current manifest hashes.
282
+ * Legacy array/true grant-resource shortcuts are not the bootstrap contract
283
+ * and never authorize a mutation.
284
+ * @param {{mutation: import("./device-identity.js").SyncMutation, offlineGrant: import("./offline-grant.js").OfflineGrant, syncManifest: Record<string, import("../configuration-types.js").NormalizedFrontendModelResourceSyncConfiguration>}} args - Validation args.
285
+ * @returns {void} Throws a client-safe error when the grant does not authorize the mutation.
286
+ */
287
+ validateGrantAgainstSyncPolicy({mutation, offlineGrant, syncManifest}) {
288
+ const grantResource = /** @type {Record<string, ?> | undefined} */ (offlineGrant.resources[mutation.model])
289
+ const grantOperations = Array.isArray(grantResource?.operations) ? grantResource.operations : []
290
+ const grantPolicyHash = grantResource?.policyHash
291
+
292
+ if (!grantResource || grantResource.enabled !== true) {
293
+ throw VelociousError.safe(`Offline grant does not authorize ${mutation.model}.`, {code: "offline-grant-denied"})
294
+ }
295
+
296
+ if (!grantOperations.includes(mutation.operation)) {
297
+ throw VelociousError.safe(`Offline grant does not authorize ${mutation.model}: ${mutation.operation}.`, {code: "offline-grant-denied"})
298
+ }
299
+
300
+ if (grantPolicyHash !== mutation.policyHash || grantPolicyHash !== syncManifest[mutation.model]?.policyHash) {
301
+ throw VelociousError.safe(`Offline grant policy hash mismatch for ${mutation.model}.`, {code: "offline-grant-policy-hash-mismatch"})
302
+ }
303
+
304
+ if (!offlineGrant.scopes || typeof offlineGrant.scopes !== "object" || Array.isArray(offlineGrant.scopes)) {
305
+ throw VelociousError.safe("Offline grant scopes are invalid.", {code: "offline-grant-scopes-invalid"})
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Transforms a verified signed mutation into a generic sync envelope.
311
+ * @param {{mutation: import("./device-identity.js").SyncMutation}} args - Transform args.
312
+ * @returns {Record<string, ?>} Sync envelope.
313
+ */
314
+ syncFromSignedMutation({mutation}) {
315
+ const operation = mutation.operation
316
+ const attributes = mutation.attributes || {}
317
+ const payload = mutation.payload || {}
318
+ let resourceId
319
+ let data
320
+
321
+ if (operation === "create") {
322
+ resourceId = String(attributes.id)
323
+ data = {...attributes}
324
+ } else if (operation === "update") {
325
+ resourceId = String(payload.id)
326
+ data = {...attributes}
327
+ } else {
328
+ // Domain commands carry their target identity in `payload.id`.
329
+ resourceId = String(payload.id)
330
+ data = {...payload}
331
+ delete data.id
332
+ }
333
+
334
+ return {
335
+ clientUpdatedAt: mutation.occurredAt,
336
+ data,
337
+ resourceId,
338
+ resourceType: mutation.model,
339
+ syncType: operation
340
+ }
341
+ }
342
+ }
@@ -64,7 +64,7 @@ export default class SyncEnvelopeReplayService {
64
64
  * @param {(args: {mutation: ?, applyResult: ?}) => ?} [args.persistSerializedData] - Overrides the persisted data payload (object results are JSON stringified).
65
65
  * @param {(broadcast: {channel: string, params: Record<string, ?>, body: ?}) => Promise<void>} [args.broadcaster] - Delivers declarative broadcasts. Required when broadcasts are configured.
66
66
  * @param {SyncReplayBroadcast[]} [args.broadcasts] - Broadcasts fanned out by the default afterReplayMutation.
67
- * @param {{getBackendProjects: () => import("../configuration-types.js").BackendProjectConfiguration[]}} [args.configuration] - Configuration whose frontend-model registry routes mutations to resource classes.
67
+ * @param {import("../configuration.js").default} [args.configuration] - Configuration whose frontend-model registry routes mutations to resource classes.
68
68
  * @param {Record<string, import("../configuration-types.js").FrontendModelResourceClassType | string>} [args.resourceTypeOverrides] - Per-resourceType routing overrides: a resource class, or a string alias resolved through the registry.
69
69
  * @param {import("../authorization/ability.js").default} [args.ability] - Ability scoping routed record lookups and create membership checks.
70
70
  * @param {Record<string, ?>} [args.abilityContext] - Ability context passed to routed resources.
@@ -116,10 +116,11 @@ export default class SyncEnvelopeReplayService {
116
116
  /**
117
117
  * Replays a sync batch.
118
118
  * @param {Record<string, ?>} params - Request params carrying authentication and syncs.
119
+ * @param {Record<string, ?>} [requestState] - Request-local state passed to authentication/sync extraction hooks; subclasses may use this to share pre-computed per-request data without instance mutation.
119
120
  * @returns {Promise<{syncs: Array<Record<string, ?>>, status?: string, errorCode?: string, errorMessage?: string}>} Replay response.
120
121
  */
121
- async replay(params) {
122
- const actorResult = await this.authenticateReplay(params)
122
+ async replay(params, requestState = {}) {
123
+ const actorResult = await this.authenticateReplay(params, requestState)
123
124
 
124
125
  if (!actorResult.authenticated) {
125
126
  return {
@@ -131,9 +132,9 @@ export default class SyncEnvelopeReplayService {
131
132
  }
132
133
 
133
134
  const syncResponses = []
134
- const context = await this.buildReplayContext({actor: actorResult.actor, params})
135
+ const context = await this.buildReplayContext({actor: actorResult.actor, params, requestState})
135
136
 
136
- for (const rawSync of this.replaySyncs(params)) {
137
+ for (const rawSync of this.replaySyncs(params, requestState)) {
137
138
  const normalizedResult = this.normalizeReplaySync(rawSync)
138
139
 
139
140
  if (!normalizedResult.ok) {
@@ -195,9 +196,10 @@ export default class SyncEnvelopeReplayService {
195
196
  * Defaults to a token-model lookup when `authenticationTokenModel` is
196
197
  * configured; otherwise apps override this hook.
197
198
  * @param {Record<string, ?>} params - Request params.
199
+ * @param {Record<string, ?>} [_requestState] - Request-local state populated by subclasses before the base replay loop runs.
198
200
  * @returns {Promise<{authenticated: true, actor: ?} | {authenticated: false, errorCode: string, errorMessage: string}>} Auth result.
199
201
  */
200
- async authenticateReplay(params) {
202
+ async authenticateReplay(params, _requestState) {
201
203
  if (!this.authenticationTokenModel) {
202
204
  throw new Error("SyncEnvelopeReplayService.authenticateReplay must be implemented (or configure authenticationTokenModel)")
203
205
  }
@@ -219,7 +221,7 @@ export default class SyncEnvelopeReplayService {
219
221
 
220
222
  /**
221
223
  * Builds per-batch mutable context for caches shared across sync items.
222
- * @param {{actor: ?, params: Record<string, ?>}} _args - Actor and request params.
224
+ * @param {{actor: ?, params: Record<string, ?>, requestState: Record<string, ?>}} _args - Actor, request params, and request-local state.
223
225
  * @returns {Promise<Record<string, ?>>} Replay context.
224
226
  */
225
227
  async buildReplayContext(_args) {
@@ -229,9 +231,10 @@ export default class SyncEnvelopeReplayService {
229
231
  /**
230
232
  * Returns raw sync entries from request params.
231
233
  * @param {Record<string, ?>} params - Request params.
234
+ * @param {Record<string, ?>} [_requestState] - Request-local state populated by subclasses before the base replay loop runs.
232
235
  * @returns {Array<?>} Raw sync entries.
233
236
  */
234
- replaySyncs(params) {
237
+ replaySyncs(params, _requestState) {
235
238
  return Array.isArray(params.syncs) ? params.syncs : []
236
239
  }
237
240
 
@@ -451,20 +454,35 @@ export default class SyncEnvelopeReplayService {
451
454
  }
452
455
  }
453
456
 
457
+ /**
458
+ * Resolves the ability and resource context used to authorize routed
459
+ * resources. Defaults to the constructor-wide ability/abilityContext;
460
+ * subclasses (signed replay) override this to derive authorization from a
461
+ * verified actor/grant instead of uploader-global state.
462
+ * @param {{actor: ?, context: Record<string, ?>}} _args - Replay actor and batch context.
463
+ * @returns {Promise<{ability: import("../authorization/ability.js").default | undefined, abilityContext: Record<string, ?>}>} Ability and resource context.
464
+ */
465
+ async replayAbilityFor(_args) {
466
+ return {ability: this.ability || undefined, abilityContext: this.abilityContext || {}}
467
+ }
468
+
454
469
  /**
455
470
  * Builds the routed resource instance handling one mutation.
456
471
  * @param {object} args - Options.
472
+ * @param {?} args.actor - Replay actor.
473
+ * @param {Record<string, ?>} args.context - Replay context.
457
474
  * @param {import("./sync-envelope-replay-service.js").SyncReplayMutation} args.mutation - Normalized replay mutation.
458
475
  * @param {SyncReplayResourceRegistration} args.registration - Resolved resource registration.
459
- * @returns {import("../frontend-model-resource/base-resource.js").default} Routed resource instance.
476
+ * @returns {Promise<import("../frontend-model-resource/base-resource.js").default>} Routed resource instance.
460
477
  */
461
- buildReplayResource({mutation, registration}) {
478
+ async buildReplayResource({actor, context, mutation, registration}) {
462
479
  const ResourceClass = registration.resourceClass
480
+ const {ability, abilityContext} = await this.replayAbilityFor({actor, context})
463
481
 
464
482
  return new ResourceClass({
465
- ability: this.ability || undefined,
466
- context: this.abilityContext || {},
467
- locals: this.locals || {},
483
+ ability,
484
+ context: abilityContext,
485
+ locals: {...(this.locals || {}), ...(this.configuration ? {configuration: this.configuration} : {})},
468
486
  modelName: registration.modelName,
469
487
  params: mutation.data,
470
488
  ...(registration.resourceConfiguration ? {resourceConfiguration: registration.resourceConfiguration} : {})
@@ -480,14 +498,14 @@ export default class SyncEnvelopeReplayService {
480
498
  * @param {{actor: ?, context: Record<string, ?>, existingSync: ?, mutation: import("./sync-envelope-replay-service.js").SyncReplayMutation}} args - Actor, batch context, existing sync row, and mutation.
481
499
  * @returns {Promise<Record<string, ?>>} Apply result with record, created/deleted flags, and afterSyncApply extras.
482
500
  */
483
- async applyRoutedReplayMutation({context, existingSync, mutation}) {
501
+ async applyRoutedReplayMutation({actor, context, existingSync, mutation}) {
484
502
  const registration = this.replayResourceRegistration(mutation.resourceType)
485
503
 
486
504
  if (!registration) {
487
505
  throw VelociousError.safe(`Unknown sync resource type: ${mutation.resourceType}.`, {code: "unknown-resource-type"})
488
506
  }
489
507
 
490
- const resource = this.buildReplayResource({mutation, registration})
508
+ const resource = await this.buildReplayResource({actor, context, mutation, registration})
491
509
  const customApplyResult = await resource.applySync({context, existingSync, mutation})
492
510
 
493
511
  if (customApplyResult !== null) return customApplyResult
@@ -500,9 +518,86 @@ export default class SyncEnvelopeReplayService {
500
518
 
501
519
  if (mutation.syncType === "delete") return await this.applyRoutedReplayDelete({mutation, resource})
502
520
 
521
+ const commandApplyResult = await this.applyRoutedReplayCommand({context, mutation, resource})
522
+
523
+ if (commandApplyResult !== null) return commandApplyResult
524
+
503
525
  return await this.applyRoutedReplayUpsert({context, mutation, resource})
504
526
  }
505
527
 
528
+ /**
529
+ * Dispatches a routed sync mutation whose syncType matches a resource-declared
530
+ * custom command. Returns null when the mutation is not a command so the
531
+ * caller can fall through to the default upsert path.
532
+ * @param {{context: Record<string, ?>, mutation: import("./sync-envelope-replay-service.js").SyncReplayMutation, resource: import("../frontend-model-resource/base-resource.js").default}} args - Command dispatch args.
533
+ * @returns {Promise<Record<string, ?> | null>} Command apply result or null.
534
+ */
535
+ async applyRoutedReplayCommand({context, mutation, resource}) {
536
+ const commandConfig = this.resourceCommandConfig(resource)
537
+ const commandMethodName = this.commandMethodNameForSyncType({commandConfig, syncType: mutation.syncType})
538
+
539
+ if (!commandMethodName) return null
540
+
541
+ const commandMethod = resource.resourceMethod(commandMethodName)
542
+
543
+ if (!commandMethod) {
544
+ throw VelociousError.safe(`Sync command handler missing for: ${mutation.resourceType}.${mutation.syncType}.`, {code: "sync-command-handler-missing"})
545
+ }
546
+
547
+ const args = this.commandArgsForMutation({commandConfig, commandMethodName, mutation})
548
+ const result = await commandMethod.method.call(commandMethod.resource, args)
549
+
550
+ const afterExtras = await resource.afterSyncApply({context, created: false, mutation, record: null})
551
+ const resultObject = result && typeof result === "object" && !Array.isArray(result) ? result : {}
552
+
553
+ return {commandResult: result, created: false, deleted: false, record: null, ...resultObject, ...afterExtras}
554
+ }
555
+
556
+ /**
557
+ * Resolves the custom-command configuration declared on a routed resource.
558
+ * @param {import("../frontend-model-resource/base-resource.js").default} resource - Routed resource instance.
559
+ * @returns {{collectionCommands: Record<string, string>, memberCommands: Record<string, string>}} Command config.
560
+ */
561
+ resourceCommandConfig(resource) {
562
+ const config = /** @type {Record<string, ?>} */ (resource.resourceConfigurationValue || {})
563
+
564
+ return {
565
+ collectionCommands: config.collectionCommands || {},
566
+ memberCommands: config.memberCommands || {}
567
+ }
568
+ }
569
+
570
+ /**
571
+ * Resolves the resource method name for a syncType when it names a declared
572
+ * custom command.
573
+ * @param {{commandConfig: {collectionCommands: Record<string, string>, memberCommands: Record<string, string>}, syncType: string}} args - Lookup args.
574
+ * @returns {string | null} Method name or null.
575
+ */
576
+ commandMethodNameForSyncType({commandConfig, syncType}) {
577
+ if (commandConfig.memberCommands[syncType]) return syncType
578
+ if (commandConfig.collectionCommands[syncType]) return syncType
579
+
580
+ return null
581
+ }
582
+
583
+ /**
584
+ * Builds the arguments object passed to a resource command method. Member
585
+ * commands receive the envelope's resourceId as `id`; the envelope identity
586
+ * is assigned after the payload so a payload `id` can never retarget the
587
+ * command away from the resource the authorization hooks approved.
588
+ * @param {{commandConfig: {collectionCommands: Record<string, string>, memberCommands: Record<string, string>}, commandMethodName: string, mutation: import("./sync-envelope-replay-service.js").SyncReplayMutation}} args - Args builder args.
589
+ * @returns {Record<string, ?>} Command method arguments.
590
+ */
591
+ commandArgsForMutation({commandConfig, commandMethodName, mutation}) {
592
+ const isMember = commandConfig.memberCommands[commandMethodName] !== undefined
593
+
594
+ if (isMember) {
595
+ return {...mutation.data, id: mutation.resourceId}
596
+ }
597
+
598
+ return {...mutation.data}
599
+ }
600
+
506
601
  /**
507
602
  * Applies a routed delete mutation. The record is marked as a server apply
508
603
  * for the duration of the replay-owned destroy - an active SyncPublisher