@structured-id/ui-core 4.9.0 → 4.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import type { MethodInfo, RpcOptions } from "@protobuf-ts/runtime-rpc";
|
|
3
|
+
import { OPERATION_KEY, operationKey } from "./operationKey";
|
|
4
|
+
|
|
5
|
+
/** The options a unary call reaches the transport with. */
|
|
6
|
+
function sent(
|
|
7
|
+
idempotency: MethodInfo["idempotency"],
|
|
8
|
+
options: RpcOptions = {},
|
|
9
|
+
) {
|
|
10
|
+
const next = vi.fn((_m, _i, o: RpcOptions) => o);
|
|
11
|
+
const method = { idempotency } as unknown as MethodInfo;
|
|
12
|
+
operationKey.interceptUnary!(next as never, method, {}, options);
|
|
13
|
+
return next.mock.calls[0][2] as RpcOptions;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("operationKey", () => {
|
|
17
|
+
// A state-changing call is named by a key, so SID can answer its retry
|
|
18
|
+
// with the recorded result instead of applying it again.
|
|
19
|
+
it("adds a key to a call that changes state", () => {
|
|
20
|
+
expect(sent(undefined).meta?.[OPERATION_KEY]).toMatch(/^[0-9a-f-]{36}$/);
|
|
21
|
+
expect(sent("IDEMPOTENT").meta?.[OPERATION_KEY]).toMatch(/^[0-9a-f-]{36}$/);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("adds no key to a read", () => {
|
|
25
|
+
expect(sent("NO_SIDE_EFFECTS").meta?.[OPERATION_KEY]).toBeUndefined();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// A caller retrying one command passes the same key every time.
|
|
29
|
+
it("keeps a key the caller passed", () => {
|
|
30
|
+
const options = { meta: { [OPERATION_KEY]: "mine", other: "x" } };
|
|
31
|
+
expect(sent(undefined, options).meta).toEqual(options.meta);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("gives every call its own key", () => {
|
|
35
|
+
expect(sent(undefined).meta?.[OPERATION_KEY]).not.toBe(
|
|
36
|
+
sent(undefined).meta?.[OPERATION_KEY],
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The operation key of state-changing calls: SID answers a retried command
|
|
3
|
+
* with its recorded result only when the call names it by a key the caller
|
|
4
|
+
* created before sending it.
|
|
5
|
+
*/
|
|
6
|
+
import type { RpcInterceptor, RpcOptions } from "@protobuf-ts/runtime-rpc";
|
|
7
|
+
|
|
8
|
+
/** The metadata entry carrying the key (the `Idempotency-Key` header). */
|
|
9
|
+
export const OPERATION_KEY = "idempotency-key";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `options` with an operation key: the caller's own when it passed one (a
|
|
13
|
+
* caller retrying one command passes the same key every time), a new one
|
|
14
|
+
* otherwise.
|
|
15
|
+
*/
|
|
16
|
+
export function withOperationKey(options: RpcOptions): RpcOptions {
|
|
17
|
+
if (options.meta?.[OPERATION_KEY]) {
|
|
18
|
+
return options;
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
...options,
|
|
22
|
+
meta: { ...options.meta, [OPERATION_KEY]: crypto.randomUUID() },
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Adds an operation key to every unary call of a method not declared free of
|
|
28
|
+
* side effects; reads carry none.
|
|
29
|
+
*/
|
|
30
|
+
export const operationKey: RpcInterceptor = {
|
|
31
|
+
interceptUnary(next, method, input, options) {
|
|
32
|
+
return next(
|
|
33
|
+
method,
|
|
34
|
+
input,
|
|
35
|
+
method.idempotency === "NO_SIDE_EFFECTS"
|
|
36
|
+
? options
|
|
37
|
+
: withOperationKey(options),
|
|
38
|
+
);
|
|
39
|
+
},
|
|
40
|
+
};
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { GrpcWebFetchTransport } from "@protobuf-ts/grpcweb-transport";
|
|
11
11
|
import type { RpcInterceptor, RpcTransport } from "@protobuf-ts/runtime-rpc";
|
|
12
12
|
import { bffCsrfToken } from "./useBffAuth";
|
|
13
|
+
import { operationKey } from "./operationKey";
|
|
13
14
|
|
|
14
15
|
/** Where the account API is reached. */
|
|
15
16
|
export interface AccountApiConfig {
|
|
@@ -49,7 +50,7 @@ export function initAccountApi(config: AccountApiConfig): void {
|
|
|
49
50
|
baseUrl: config.baseUrl,
|
|
50
51
|
// The BFF is on this origin; its cookie must not travel anywhere else.
|
|
51
52
|
fetchInit: { credentials: "same-origin" },
|
|
52
|
-
interceptors: [csrf],
|
|
53
|
+
interceptors: [csrf, operationKey],
|
|
53
54
|
});
|
|
54
55
|
}
|
|
55
56
|
|
|
@@ -14,6 +14,7 @@ vi.mock("@protobuf-ts/grpcweb-transport", () => ({
|
|
|
14
14
|
// Import AFTER mocks
|
|
15
15
|
import { GrpcWebFetchTransport } from "@protobuf-ts/grpcweb-transport";
|
|
16
16
|
import { initGrpc, getTransport, closeTransport, useGrpc } from "./useGrpc";
|
|
17
|
+
import { operationKey } from "./operationKey";
|
|
17
18
|
import type { GrpcConfig } from "../types/auth";
|
|
18
19
|
|
|
19
20
|
// ---------------------------------------------------------------------------
|
|
@@ -28,7 +29,7 @@ describe("useGrpc", () => {
|
|
|
28
29
|
|
|
29
30
|
describe("initGrpc", () => {
|
|
30
31
|
// The BFF session cookie rides on every call, so credentials are always
|
|
31
|
-
// included;
|
|
32
|
+
// included; state-changing calls carry an operation key.
|
|
32
33
|
it("creates GrpcWebFetchTransport with baseUrl and credentials", () => {
|
|
33
34
|
const config: GrpcConfig = { baseUrl: "https://api.sid.example.com" };
|
|
34
35
|
initGrpc(config);
|
|
@@ -36,6 +37,7 @@ describe("useGrpc", () => {
|
|
|
36
37
|
expect(GrpcWebFetchTransport).toHaveBeenCalledWith({
|
|
37
38
|
baseUrl: "https://api.sid.example.com",
|
|
38
39
|
fetchInit: { credentials: "include" },
|
|
40
|
+
interceptors: [operationKey],
|
|
39
41
|
});
|
|
40
42
|
});
|
|
41
43
|
});
|
|
@@ -2,6 +2,7 @@ import { GrpcWebFetchTransport } from "@protobuf-ts/grpcweb-transport";
|
|
|
2
2
|
import type { RpcTransport } from "@protobuf-ts/runtime-rpc";
|
|
3
3
|
import { ref, type Ref } from "vue";
|
|
4
4
|
import type { GrpcConfig } from "../types/auth";
|
|
5
|
+
import { operationKey } from "./operationKey";
|
|
5
6
|
|
|
6
7
|
/** Shared gRPC-web transport instance. */
|
|
7
8
|
let _transport: RpcTransport | null = null;
|
|
@@ -23,6 +24,7 @@ export function initGrpc(config: GrpcConfig): void {
|
|
|
23
24
|
// The BFF proxy exchanges the session cookie for a Bearer token
|
|
24
25
|
// server-side.
|
|
25
26
|
fetchInit: { credentials: "include" },
|
|
27
|
+
interceptors: [operationKey],
|
|
26
28
|
});
|
|
27
29
|
}
|
|
28
30
|
|