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.
- package/README.md +2 -0
- package/build/background-jobs/web/authorization.js +2 -33
- package/build/background-jobs/web/path-matcher.js +5 -30
- package/build/database/drivers/base.js +17 -0
- package/build/database/drivers/mssql/index.js +30 -0
- package/build/database/record/index.js +22 -10
- package/build/deployment-api/controller.js +437 -0
- package/build/deployment-api/index.js +210 -0
- package/build/deployment-api/path-matcher.js +45 -0
- package/build/deployment-api/registry.js +84 -0
- package/build/deployment-api/run-store.js +798 -0
- package/build/deployment-api/sanitize.js +114 -0
- package/build/http-client/request.js +3 -1
- package/build/src/background-jobs/web/authorization.d.ts.map +1 -1
- package/build/src/background-jobs/web/authorization.js +3 -29
- package/build/src/background-jobs/web/path-matcher.d.ts +2 -12
- package/build/src/background-jobs/web/path-matcher.d.ts.map +1 -1
- package/build/src/background-jobs/web/path-matcher.js +5 -30
- package/build/src/database/drivers/base.d.ts +16 -0
- package/build/src/database/drivers/base.d.ts.map +1 -1
- package/build/src/database/drivers/base.js +16 -1
- package/build/src/database/drivers/mssql/index.d.ts.map +1 -1
- package/build/src/database/drivers/mssql/index.js +29 -1
- package/build/src/database/record/index.d.ts +3 -2
- package/build/src/database/record/index.d.ts.map +1 -1
- package/build/src/database/record/index.js +21 -10
- package/build/src/deployment-api/controller.d.ts +117 -0
- package/build/src/deployment-api/controller.d.ts.map +1 -0
- package/build/src/deployment-api/controller.js +384 -0
- package/build/src/deployment-api/index.d.ts +46 -0
- package/build/src/deployment-api/index.d.ts.map +1 -0
- package/build/src/deployment-api/index.js +178 -0
- package/build/src/deployment-api/path-matcher.d.ts +31 -0
- package/build/src/deployment-api/path-matcher.d.ts.map +1 -0
- package/build/src/deployment-api/path-matcher.js +39 -0
- package/build/src/deployment-api/registry.d.ts +106 -0
- package/build/src/deployment-api/registry.d.ts.map +1 -0
- package/build/src/deployment-api/registry.js +74 -0
- package/build/src/deployment-api/run-store.d.ts +402 -0
- package/build/src/deployment-api/run-store.d.ts.map +1 -0
- package/build/src/deployment-api/run-store.js +711 -0
- package/build/src/deployment-api/sanitize.d.ts +27 -0
- package/build/src/deployment-api/sanitize.d.ts.map +1 -0
- package/build/src/deployment-api/sanitize.js +100 -0
- package/build/src/http-client/request.d.ts.map +1 -1
- package/build/src/http-client/request.js +4 -2
- package/build/src/sync/signed-sync-envelope-replay-service.d.ts +154 -0
- package/build/src/sync/signed-sync-envelope-replay-service.d.ts.map +1 -0
- package/build/src/sync/signed-sync-envelope-replay-service.js +294 -0
- package/build/src/sync/sync-envelope-replay-service.d.ts +84 -15
- package/build/src/sync/sync-envelope-replay-service.d.ts.map +1 -1
- package/build/src/sync/sync-envelope-replay-service.js +98 -16
- package/build/src/utils/bearer-token.d.ts +15 -0
- package/build/src/utils/bearer-token.d.ts.map +1 -0
- package/build/src/utils/bearer-token.js +29 -0
- package/build/src/utils/mount-prefix.d.ts +21 -0
- package/build/src/utils/mount-prefix.d.ts.map +1 -0
- package/build/src/utils/mount-prefix.js +35 -0
- package/build/sync/signed-sync-envelope-replay-service.js +342 -0
- package/build/sync/sync-envelope-replay-service.js +110 -15
- package/build/tsconfig.tsbuildinfo +1 -1
- package/build/utils/bearer-token.js +34 -0
- package/build/utils/mount-prefix.js +36 -0
- package/package.json +2 -1
- package/src/background-jobs/web/authorization.js +2 -33
- package/src/background-jobs/web/path-matcher.js +5 -30
- package/src/database/drivers/base.js +17 -0
- package/src/database/drivers/mssql/index.js +30 -0
- package/src/database/record/index.js +22 -10
- package/src/deployment-api/controller.js +437 -0
- package/src/deployment-api/index.js +210 -0
- package/src/deployment-api/path-matcher.js +45 -0
- package/src/deployment-api/registry.js +84 -0
- package/src/deployment-api/run-store.js +798 -0
- package/src/deployment-api/sanitize.js +114 -0
- package/src/http-client/request.js +3 -1
- package/src/sync/signed-sync-envelope-replay-service.js +342 -0
- package/src/sync/sync-envelope-replay-service.js +110 -15
- package/src/utils/bearer-token.js +34 -0
- 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 {
|
|
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
|
|
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
|
|
466
|
-
context:
|
|
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
|