@stigmer/temporal-codecs 3.12.9

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 (42) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +48 -0
  3. package/claimcheck/compressor.d.ts +12 -0
  4. package/claimcheck/compressor.d.ts.map +1 -0
  5. package/claimcheck/compressor.js +17 -0
  6. package/claimcheck/compressor.js.map +1 -0
  7. package/claimcheck/config.d.ts +17 -0
  8. package/claimcheck/config.d.ts.map +1 -0
  9. package/claimcheck/config.js +19 -0
  10. package/claimcheck/config.js.map +1 -0
  11. package/claimcheck/payload-codec.d.ts +29 -0
  12. package/claimcheck/payload-codec.d.ts.map +1 -0
  13. package/claimcheck/payload-codec.js +110 -0
  14. package/claimcheck/payload-codec.js.map +1 -0
  15. package/claimcheck/storage.d.ts +20 -0
  16. package/claimcheck/storage.d.ts.map +1 -0
  17. package/claimcheck/storage.js +2 -0
  18. package/claimcheck/storage.js.map +1 -0
  19. package/encryption/config.d.ts +82 -0
  20. package/encryption/config.d.ts.map +1 -0
  21. package/encryption/config.js +119 -0
  22. package/encryption/config.js.map +1 -0
  23. package/encryption/payload-codec.d.ts +46 -0
  24. package/encryption/payload-codec.d.ts.map +1 -0
  25. package/encryption/payload-codec.js +134 -0
  26. package/encryption/payload-codec.js.map +1 -0
  27. package/index.d.ts +28 -0
  28. package/index.d.ts.map +1 -0
  29. package/index.js +25 -0
  30. package/index.js.map +1 -0
  31. package/package.json +36 -0
  32. package/src/__test-utils__/fake-claimcheck-storage.ts +54 -0
  33. package/src/__tests__/claimcheck-codec.test.ts +256 -0
  34. package/src/__tests__/encryption-codec.test.ts +292 -0
  35. package/src/__tests__/fixtures/encrypted-payload-fixture.json +15 -0
  36. package/src/claimcheck/compressor.ts +19 -0
  37. package/src/claimcheck/config.ts +30 -0
  38. package/src/claimcheck/payload-codec.ts +144 -0
  39. package/src/claimcheck/storage.ts +19 -0
  40. package/src/encryption/config.ts +174 -0
  41. package/src/encryption/payload-codec.ts +156 -0
  42. package/src/index.ts +34 -0
@@ -0,0 +1,256 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { ClaimcheckPayloadCodec } from "../claimcheck/payload-codec.js";
3
+ import { compress } from "../claimcheck/compressor.js";
4
+ import type { ClaimcheckConfig } from "../claimcheck/config.js";
5
+ import { makeInMemoryClaimcheckStorage } from "../__test-utils__/fake-claimcheck-storage.js";
6
+ import type { Payload } from "@temporalio/common";
7
+
8
+ function makeConfig(overrides: Partial<ClaimcheckConfig> = {}): ClaimcheckConfig {
9
+ return {
10
+ enabled: true,
11
+ thresholdBytes: 1024,
12
+ compressionEnabled: true,
13
+ keyPrefix: "claimcheck/",
14
+ ...overrides,
15
+ };
16
+ }
17
+
18
+ function makeStorage() {
19
+ // The canonical in-memory double; `uploads` aliases its backing Map so the
20
+ // existing `storage.uploads.*` assertions keep working, and `download` reads
21
+ // straight from what `encode` uploaded (no fetch to stub).
22
+ const { storage, blobs } = makeInMemoryClaimcheckStorage();
23
+ return Object.assign(storage, { uploads: blobs });
24
+ }
25
+
26
+ function makePayload(data: string | Buffer): Payload {
27
+ const buf = typeof data === "string" ? Buffer.from(data) : data;
28
+ return {
29
+ metadata: { encoding: Buffer.from("binary/plain") },
30
+ data: buf,
31
+ };
32
+ }
33
+
34
+ function makeLargePayload(sizeBytes: number): Payload {
35
+ const buf = Buffer.alloc(sizeBytes, "x");
36
+ return {
37
+ metadata: { encoding: Buffer.from("binary/plain") },
38
+ data: buf,
39
+ };
40
+ }
41
+
42
+ describe("ClaimcheckPayloadCodec", () => {
43
+ let storage: ReturnType<typeof makeStorage>;
44
+ let codec: ClaimcheckPayloadCodec;
45
+
46
+ beforeEach(() => {
47
+ storage = makeStorage();
48
+ codec = new ClaimcheckPayloadCodec(storage, makeConfig());
49
+ });
50
+
51
+ describe("encode", () => {
52
+ it("passes through payloads below threshold unchanged", async () => {
53
+ const payload = makePayload("small data");
54
+ const [result] = await codec.encode([payload]);
55
+ expect(result).toBe(payload);
56
+ expect(storage.uploads.size).toBe(0);
57
+ });
58
+
59
+ it("passes through empty payloads", async () => {
60
+ const payload: Payload = { metadata: {}, data: undefined };
61
+ const [result] = await codec.encode([payload]);
62
+ expect(result).toBe(payload);
63
+ });
64
+
65
+ it("offloads payloads at or above threshold", async () => {
66
+ const payload = makeLargePayload(1024);
67
+ const [result] = await codec.encode([payload]);
68
+
69
+ expect(result).not.toBe(payload);
70
+ expect(storage.uploads.size).toBe(1);
71
+
72
+ const markerMeta = result.metadata?.["encoding"];
73
+ expect(Buffer.from(markerMeta!).toString()).toBe("binary/claimcheck");
74
+
75
+ const marker = JSON.parse(Buffer.from(result.data!).toString());
76
+ expect(marker.key).toMatch(/^claimcheck\//);
77
+ expect(marker.size).toBe(1024);
78
+ expect(marker.compressed).toBe(true);
79
+ });
80
+
81
+ it("skips compression when it does not reduce size", async () => {
82
+ const randomBuf = Buffer.from(
83
+ Array.from({ length: 1024 }, () => Math.floor(Math.random() * 256)),
84
+ );
85
+ const payload: Payload = {
86
+ metadata: { encoding: Buffer.from("binary/plain") },
87
+ data: randomBuf,
88
+ };
89
+
90
+ const compressedSize = compress(randomBuf).length;
91
+ if (compressedSize >= randomBuf.length) {
92
+ const [result] = await codec.encode([payload]);
93
+ const marker = JSON.parse(Buffer.from(result.data!).toString());
94
+ expect(marker.compressed).toBe(false);
95
+
96
+ const uploaded = storage.uploads.values().next().value!;
97
+ expect(uploaded.length).toBe(randomBuf.length);
98
+ }
99
+ });
100
+
101
+ it("does not compress when compression is disabled", async () => {
102
+ codec = new ClaimcheckPayloadCodec(
103
+ storage,
104
+ makeConfig({ compressionEnabled: false }),
105
+ );
106
+ const payload = makeLargePayload(1024);
107
+ const [result] = await codec.encode([payload]);
108
+
109
+ const marker = JSON.parse(Buffer.from(result.data!).toString());
110
+ expect(marker.compressed).toBe(false);
111
+
112
+ const uploaded = storage.uploads.values().next().value!;
113
+ expect(uploaded.length).toBe(1024);
114
+ });
115
+
116
+ it("handles multiple payloads in a batch", async () => {
117
+ const small = makePayload("tiny");
118
+ const large = makeLargePayload(2048);
119
+
120
+ const results = await codec.encode([small, large]);
121
+ expect(results[0]).toBe(small);
122
+ expect(results[1]).not.toBe(large);
123
+ expect(storage.uploads.size).toBe(1);
124
+ });
125
+
126
+ it("uses configured key prefix", async () => {
127
+ codec = new ClaimcheckPayloadCodec(
128
+ storage,
129
+ makeConfig({ keyPrefix: "custom-prefix/" }),
130
+ );
131
+ const payload = makeLargePayload(1024);
132
+ const [result] = await codec.encode([payload]);
133
+
134
+ const marker = JSON.parse(Buffer.from(result.data!).toString());
135
+ expect(marker.key).toMatch(/^custom-prefix\//);
136
+ });
137
+ });
138
+
139
+ describe("decode", () => {
140
+ it("passes through non-claimcheck payloads unchanged", async () => {
141
+ const payload = makePayload("normal data");
142
+ const [result] = await codec.decode([payload]);
143
+ expect(result).toBe(payload);
144
+ });
145
+
146
+ it("retrieves and decompresses offloaded payloads (round-trip)", async () => {
147
+ const original = makeLargePayload(2048);
148
+ const [encoded] = await codec.encode([original]);
149
+
150
+ // decode reads back through storage.download from what encode uploaded.
151
+ const [decoded] = await codec.decode([encoded]);
152
+ expect(Buffer.from(decoded.data!)).toEqual(original.data);
153
+ });
154
+
155
+ it("restores the original payload metadata (round-trip)", async () => {
156
+ // The restored payload must carry its ORIGINAL encoding, not the
157
+ // marker's binary/claimcheck — otherwise no payload converter can
158
+ // interpret it and composition with the encryption codec breaks.
159
+ const original: Payload = {
160
+ metadata: {
161
+ encoding: Buffer.from("json/plain"),
162
+ "custom-key": Buffer.from("custom-value"),
163
+ },
164
+ data: Buffer.alloc(2048, "y"),
165
+ };
166
+
167
+ const [encoded] = await codec.encode([original]);
168
+ const [decoded] = await codec.decode([encoded]);
169
+
170
+ expect(Buffer.from(decoded.metadata!["encoding"]!).toString()).toBe("json/plain");
171
+ expect(Buffer.from(decoded.metadata!["custom-key"]!).toString()).toBe("custom-value");
172
+ expect(Buffer.from(decoded.data!)).toEqual(original.data);
173
+ });
174
+
175
+ it("decodes legacy markers written before metadata preservation", async () => {
176
+ const original = makeLargePayload(2048);
177
+ const [encoded] = await codec.encode([original]);
178
+
179
+ // Simulate a pre-fix marker: strip the metadata field.
180
+ const marker = JSON.parse(Buffer.from(encoded.data!).toString());
181
+ delete marker.metadata;
182
+ const legacy: Payload = {
183
+ metadata: encoded.metadata,
184
+ data: Buffer.from(JSON.stringify(marker)),
185
+ };
186
+
187
+ const [decoded] = await codec.decode([legacy]);
188
+ expect(Buffer.from(decoded.data!)).toEqual(original.data);
189
+ });
190
+
191
+ it("retrieves uncompressed payloads correctly", async () => {
192
+ codec = new ClaimcheckPayloadCodec(
193
+ storage,
194
+ makeConfig({ compressionEnabled: false }),
195
+ );
196
+
197
+ const original = makeLargePayload(1500);
198
+ const [encoded] = await codec.encode([original]);
199
+
200
+ const [decoded] = await codec.decode([encoded]);
201
+ expect(Buffer.from(decoded.data!)).toEqual(original.data);
202
+ });
203
+
204
+ it("wraps a download failure with the claimcheck error contract", async () => {
205
+ const encoded: Payload = {
206
+ metadata: { encoding: Buffer.from("binary/claimcheck") },
207
+ data: Buffer.from(JSON.stringify({
208
+ key: "claimcheck/missing",
209
+ size: 100,
210
+ compressed: false,
211
+ })),
212
+ };
213
+
214
+ // Drive the failure through the port; the proxy download surfaces the HTTP
215
+ // status, which the codec must preserve in its wrapped, key-scoped message.
216
+ storage.download.mockRejectedValueOnce(
217
+ new Error("Artifact download failed (HTTP 404) for key 'claimcheck/missing'"),
218
+ );
219
+
220
+ await expect(codec.decode([encoded])).rejects.toThrow(
221
+ /Claimcheck retrieve failed for key claimcheck\/missing.*HTTP 404/,
222
+ );
223
+ });
224
+ });
225
+
226
+ describe("threshold boundary", () => {
227
+ it("does NOT offload at threshold - 1", async () => {
228
+ const payload = makeLargePayload(1023);
229
+ const [result] = await codec.encode([payload]);
230
+ expect(result).toBe(payload);
231
+ });
232
+
233
+ it("offloads at exactly threshold", async () => {
234
+ const payload = makeLargePayload(1024);
235
+ const [result] = await codec.encode([payload]);
236
+ expect(result).not.toBe(payload);
237
+ });
238
+
239
+ it("offloads above threshold", async () => {
240
+ const payload = makeLargePayload(1025);
241
+ const [result] = await codec.encode([payload]);
242
+ expect(result).not.toBe(payload);
243
+ });
244
+ });
245
+
246
+ describe("compressor", () => {
247
+ it("compresses and decompresses correctly", async () => {
248
+ const { compress: c, decompress: d } = await import("../claimcheck/compressor.js");
249
+ const input = Buffer.from("hello world ".repeat(100));
250
+ const compressed = c(input);
251
+ expect(compressed.length).toBeLessThan(input.length);
252
+ const decompressed = d(compressed);
253
+ expect(decompressed).toEqual(input);
254
+ });
255
+ });
256
+ });
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Unit tests for the payload-encryption codec (stigmer-cloud#227).
3
+ *
4
+ * Covers the codec in isolation, its composition with the claim-check
5
+ * codec (order is load-bearing: encrypt before relocate, so object
6
+ * storage only ever sees ciphertext), the config loader's fail-fast
7
+ * contract, and the committed cross-language conformance fixture that
8
+ * pins the envelope format the Java decode-only codec (stigmer-cloud
9
+ * temporal-starter) must match.
10
+ */
11
+
12
+ import { describe, it, expect, afterEach } from "vitest";
13
+ import { randomBytes } from "node:crypto";
14
+ import { readFileSync } from "node:fs";
15
+ import { join, dirname } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import type { Payload } from "@temporalio/common";
18
+ import { EncryptionPayloadCodec } from "../encryption/payload-codec.js";
19
+ import { loadPayloadEncryptionConfig } from "../encryption/config.js";
20
+ import type { PayloadEncryptionConfig } from "../encryption/config.js";
21
+ import { ClaimcheckPayloadCodec } from "../claimcheck/payload-codec.js";
22
+ import { makeInMemoryClaimcheckStorage } from "../__test-utils__/fake-claimcheck-storage.js";
23
+
24
+ const __dirname = dirname(fileURLToPath(import.meta.url));
25
+
26
+ function makeKeyConfig(overrides: Partial<PayloadEncryptionConfig> = {}): PayloadEncryptionConfig {
27
+ return {
28
+ primary: { keyId: "test-key-1", key: randomBytes(32) },
29
+ ...overrides,
30
+ };
31
+ }
32
+
33
+ function makeJsonPayload(value: unknown): Payload {
34
+ return {
35
+ metadata: { encoding: Buffer.from("json/plain") },
36
+ data: Buffer.from(JSON.stringify(value)),
37
+ };
38
+ }
39
+
40
+ function metadataString(payload: Payload, key: string): string | undefined {
41
+ const bytes = payload.metadata?.[key];
42
+ return bytes ? Buffer.from(bytes).toString("utf-8") : undefined;
43
+ }
44
+
45
+ describe("EncryptionPayloadCodec", () => {
46
+ it("round-trips a payload, restoring metadata and data exactly", async () => {
47
+ const codec = new EncryptionPayloadCodec(makeKeyConfig());
48
+ const original = makeJsonPayload({ secret: "s3cret-value" });
49
+
50
+ const [encoded] = await codec.encode([original]);
51
+ expect(metadataString(encoded, "encoding")).toBe("binary/encrypted");
52
+ expect(metadataString(encoded, "encryption-key-id")).toBe("test-key-1");
53
+ expect(Buffer.from(encoded.data!).includes("s3cret-value")).toBe(false);
54
+
55
+ const [decoded] = await codec.decode([encoded]);
56
+ expect(metadataString(decoded, "encoding")).toBe("json/plain");
57
+ expect(Buffer.from(decoded.data!)).toEqual(original.data);
58
+ });
59
+
60
+ it("passes through payloads it did not encode", async () => {
61
+ const codec = new EncryptionPayloadCodec(makeKeyConfig());
62
+ const plaintext = makeJsonPayload({ from: "java-orchestrator" });
63
+ const [decoded] = await codec.decode([plaintext]);
64
+ expect(decoded).toBe(plaintext);
65
+ });
66
+
67
+ it("skips data-less payloads on encode (binary/null void results)", async () => {
68
+ const codec = new EncryptionPayloadCodec(makeKeyConfig());
69
+ const nullPayload: Payload = {
70
+ metadata: { encoding: Buffer.from("binary/null") },
71
+ data: undefined,
72
+ };
73
+ const [encoded] = await codec.encode([nullPayload]);
74
+ expect(encoded).toBe(nullPayload);
75
+ });
76
+
77
+ it("fails closed on tampered ciphertext", async () => {
78
+ const codec = new EncryptionPayloadCodec(makeKeyConfig());
79
+ const [encoded] = await codec.encode([makeJsonPayload({ a: 1 })]);
80
+
81
+ const tampered = Buffer.from(encoded.data!);
82
+ tampered[tampered.length - 1] ^= 0xff;
83
+
84
+ await expect(
85
+ codec.decode([{ metadata: encoded.metadata, data: tampered }]),
86
+ ).rejects.toThrow(/corrupt or the configured key does not match/);
87
+ });
88
+
89
+ it("fails closed on an unknown key id", async () => {
90
+ const writer = new EncryptionPayloadCodec(makeKeyConfig());
91
+ const [encoded] = await writer.encode([makeJsonPayload({ a: 1 })]);
92
+
93
+ const reader = new EncryptionPayloadCodec(
94
+ makeKeyConfig({ primary: { keyId: "other-key", key: randomBytes(32) } }),
95
+ );
96
+ await expect(reader.decode([encoded])).rejects.toThrow(
97
+ /unknown key id 'test-key-1'/,
98
+ );
99
+ });
100
+
101
+ it("fails closed when the key id metadata is missing", async () => {
102
+ const codec = new EncryptionPayloadCodec(makeKeyConfig());
103
+ const [encoded] = await codec.encode([makeJsonPayload({ a: 1 })]);
104
+
105
+ const stripped: Payload = {
106
+ metadata: { encoding: Buffer.from("binary/encrypted") },
107
+ data: encoded.data,
108
+ };
109
+ await expect(codec.decode([stripped])).rejects.toThrow(
110
+ /missing its encryption-key-id/,
111
+ );
112
+ });
113
+
114
+ it("decodes payloads written under the secondary key (rotation window)", async () => {
115
+ const oldKey = { keyId: "2026-01", key: randomBytes(32) };
116
+ const writer = new EncryptionPayloadCodec({ primary: oldKey });
117
+ const [encoded] = await writer.encode([makeJsonPayload({ rotated: true })]);
118
+
119
+ const rotatedReader = new EncryptionPayloadCodec({
120
+ primary: { keyId: "2026-08", key: randomBytes(32) },
121
+ secondary: oldKey,
122
+ });
123
+ const [decoded] = await rotatedReader.decode([encoded]);
124
+ expect(JSON.parse(Buffer.from(decoded.data!).toString())).toEqual({ rotated: true });
125
+ });
126
+ });
127
+
128
+ describe("composition with claim-check (encrypt, then relocate)", () => {
129
+ it("stores only ciphertext in object storage and round-trips exactly", async () => {
130
+ const { storage, blobs } = makeInMemoryClaimcheckStorage();
131
+ const encryption = new EncryptionPayloadCodec(makeKeyConfig());
132
+ const claimcheck = new ClaimcheckPayloadCodec(storage, {
133
+ enabled: true,
134
+ thresholdBytes: 128,
135
+ compressionEnabled: false,
136
+ keyPrefix: "claimcheck/",
137
+ });
138
+
139
+ const secret = "very-large-and-very-secret-".repeat(32);
140
+ const original = makeJsonPayload({ secret });
141
+
142
+ // Temporal applies codec arrays in order on encode, reverse on decode.
143
+ const [encrypted] = await encryption.encode([original]);
144
+ const [relocated] = await claimcheck.encode([encrypted]);
145
+ expect(metadataString(relocated, "encoding")).toBe("binary/claimcheck");
146
+
147
+ expect(blobs.size).toBe(1);
148
+ const blob: Buffer = blobs.values().next().value!;
149
+ expect(blob.includes("very-large-and-very-secret-")).toBe(false);
150
+
151
+ const [restored] = await claimcheck.decode([relocated]);
152
+ const [decrypted] = await encryption.decode([restored]);
153
+ expect(metadataString(decrypted, "encoding")).toBe("json/plain");
154
+ expect(Buffer.from(decrypted.data!)).toEqual(original.data);
155
+ });
156
+ });
157
+
158
+ describe("loadPayloadEncryptionConfig", () => {
159
+ const ENV_VARS = [
160
+ "STIGMER_PAYLOAD_ENCRYPTION_KEY",
161
+ "STIGMER_PAYLOAD_ENCRYPTION_KEY_ID",
162
+ "STIGMER_PAYLOAD_ENCRYPTION_SECONDARY_KEY",
163
+ "STIGMER_PAYLOAD_ENCRYPTION_SECONDARY_KEY_ID",
164
+ ];
165
+
166
+ // Consumers inject their secret custody (see SecretReader); a plain env
167
+ // read is exactly what these tests exercised before the extraction, when
168
+ // the runner's getRunnerSecret fell back to process.env with no capture.
169
+ const readEnv = (name: string) => process.env[name];
170
+
171
+ afterEach(() => {
172
+ for (const name of ENV_VARS) delete process.env[name];
173
+ });
174
+
175
+ it("returns undefined when no key is configured", () => {
176
+ expect(loadPayloadEncryptionConfig(readEnv)).toBeUndefined();
177
+ });
178
+
179
+ it("loads primary and secondary keys", () => {
180
+ process.env.STIGMER_PAYLOAD_ENCRYPTION_KEY = randomBytes(32).toString("base64");
181
+ process.env.STIGMER_PAYLOAD_ENCRYPTION_KEY_ID = "k2";
182
+ process.env.STIGMER_PAYLOAD_ENCRYPTION_SECONDARY_KEY = randomBytes(32).toString("base64");
183
+ process.env.STIGMER_PAYLOAD_ENCRYPTION_SECONDARY_KEY_ID = "k1";
184
+
185
+ const config = loadPayloadEncryptionConfig(readEnv);
186
+ expect(config?.primary.keyId).toBe("k2");
187
+ expect(config?.primary.key.length).toBe(32);
188
+ expect(config?.secondary?.keyId).toBe("k1");
189
+ });
190
+
191
+ it("fails the boot when a key is set without an id", () => {
192
+ process.env.STIGMER_PAYLOAD_ENCRYPTION_KEY = randomBytes(32).toString("base64");
193
+ expect(() => loadPayloadEncryptionConfig(readEnv)).toThrow(
194
+ /STIGMER_PAYLOAD_ENCRYPTION_KEY_ID is required/,
195
+ );
196
+ });
197
+
198
+ it("fails the boot on a key of the wrong length", () => {
199
+ process.env.STIGMER_PAYLOAD_ENCRYPTION_KEY = randomBytes(16).toString("base64");
200
+ process.env.STIGMER_PAYLOAD_ENCRYPTION_KEY_ID = "k1";
201
+ expect(() => loadPayloadEncryptionConfig(readEnv)).toThrow(/must decode to 32 bytes/);
202
+ });
203
+
204
+ // Server-managed key material from getRunnerBootstrapConfig (stigmer#398):
205
+ // desktop-class runners receive a per-identity key at bootstrap instead of
206
+ // env config. The env key is the operator's explicit choice and must win.
207
+ describe("bootstrap-delivered keys", () => {
208
+ const bootstrapKeys = () => ({
209
+ key: randomBytes(32).toString("base64"),
210
+ keyId: "identity-key-v1",
211
+ secondaryKey: randomBytes(32).toString("base64"),
212
+ secondaryKeyId: "identity-key-v0",
213
+ });
214
+
215
+ it("enables encryption from bootstrap material when no env key is set", () => {
216
+ const config = loadPayloadEncryptionConfig(readEnv, bootstrapKeys());
217
+ expect(config?.primary.keyId).toBe("identity-key-v1");
218
+ expect(config?.primary.key.length).toBe(32);
219
+ expect(config?.secondary?.keyId).toBe("identity-key-v0");
220
+ });
221
+
222
+ it("env-configured key wins over bootstrap material", () => {
223
+ process.env.STIGMER_PAYLOAD_ENCRYPTION_KEY = randomBytes(32).toString("base64");
224
+ process.env.STIGMER_PAYLOAD_ENCRYPTION_KEY_ID = "env-key";
225
+
226
+ const config = loadPayloadEncryptionConfig(readEnv, bootstrapKeys());
227
+ expect(config?.primary.keyId).toBe("env-key");
228
+ expect(config?.secondary).toBeUndefined();
229
+ });
230
+
231
+ it("fails the boot on a bootstrap key without its id (server contract violation)", () => {
232
+ expect(() =>
233
+ loadPayloadEncryptionConfig(readEnv, { key: randomBytes(32).toString("base64") }),
234
+ ).toThrow(/payload_encryption_key_id/);
235
+ });
236
+
237
+ it("fails the boot on a malformed bootstrap key rather than running plaintext", () => {
238
+ expect(() =>
239
+ loadPayloadEncryptionConfig(readEnv, {
240
+ key: randomBytes(16).toString("base64"),
241
+ keyId: "identity-key-v1",
242
+ }),
243
+ ).toThrow(/must decode to 32 bytes/);
244
+ });
245
+
246
+ it("fails the boot on a bootstrap secondary key without its id", () => {
247
+ expect(() =>
248
+ loadPayloadEncryptionConfig(readEnv, {
249
+ key: randomBytes(32).toString("base64"),
250
+ keyId: "identity-key-v1",
251
+ secondaryKey: randomBytes(32).toString("base64"),
252
+ }),
253
+ ).toThrow(/payload_encryption_secondary_key_id/);
254
+ });
255
+ });
256
+ });
257
+
258
+ describe("cross-language conformance fixture", () => {
259
+ // The committed fixture pins the envelope as a wire contract. A copy
260
+ // lives in stigmer-cloud (temporal-starter test resources) where the
261
+ // Java decode-only codec must decrypt it to the same payload. Never
262
+ // regenerate it casually: changing the fixture means changing the
263
+ // cross-SDK envelope, which requires both implementations to move in
264
+ // lockstep.
265
+ it("decrypts the committed fixture to the expected payload", async () => {
266
+ const fixture = JSON.parse(
267
+ readFileSync(join(__dirname, "fixtures", "encrypted-payload-fixture.json"), "utf-8"),
268
+ );
269
+
270
+ const codec = new EncryptionPayloadCodec({
271
+ primary: {
272
+ keyId: fixture.keyId,
273
+ key: Buffer.from(fixture.keyBase64, "base64"),
274
+ },
275
+ });
276
+
277
+ const encrypted: Payload = {
278
+ metadata: Object.fromEntries(
279
+ Object.entries(fixture.encrypted.metadataBase64 as Record<string, string>).map(
280
+ ([k, v]) => [k, Buffer.from(v, "base64")],
281
+ ),
282
+ ),
283
+ data: Buffer.from(fixture.encrypted.dataBase64, "base64"),
284
+ };
285
+
286
+ const [decoded] = await codec.decode([encrypted]);
287
+ expect(metadataString(decoded, "encoding")).toBe("json/plain");
288
+ expect(Buffer.from(decoded.data!).toString("utf-8")).toBe(
289
+ fixture.original.dataJson,
290
+ );
291
+ });
292
+ });
@@ -0,0 +1,15 @@
1
+ {
2
+ "description": "Cross-language conformance fixture for the Temporal payload-encryption envelope (stigmer-cloud#227). data = iv(12) || AES-256-GCM ciphertext || tag(16); plaintext is the serialized original Payload proto. The TS codec (stigmer OSS runner) and the Java decode-only codec (stigmer-cloud temporal-starter) must both decrypt this to original.dataJson with encoding json/plain. The key is TEST-ONLY. Do not regenerate without moving both implementations in lockstep.",
3
+ "keyId": "conformance-2026-08-11",
4
+ "keyBase64": "LwN0jDzOwm0aPH8lyRRqsTMali/7RAgKKB9CY6psNFw=",
5
+ "encrypted": {
6
+ "metadataBase64": {
7
+ "encoding": "YmluYXJ5L2VuY3J5cHRlZA==",
8
+ "encryption-key-id": "Y29uZm9ybWFuY2UtMjAyNi0wOC0xMQ=="
9
+ },
10
+ "dataBase64": "CzL44tAoZwoawET5ApaDKGLYcJO2P8eTPS4hZnoEOUvZ7jkD5mkCnD9krgY9etYWO+06y779i8xRCs7QrnOKo5xo74HtlY0bwxYjr2dOra9TGRbtv3PlrW+DcfmEFAQ3Efem"
11
+ },
12
+ "original": {
13
+ "dataJson": "{\"secret\":\"cross-language-conformance-value\"}"
14
+ }
15
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Gzip helpers for claim-checked blobs. Synchronous by design: the codec
3
+ * runs on the Temporal worker's payload path where the blobs are bounded
4
+ * by the claim-check threshold, and gzip's format is part of the marker
5
+ * contract (`compressed: true` blobs must gunzip on any consumer).
6
+ *
7
+ * Moved from backend/services/runner/src/claimcheck/compressor.ts when the
8
+ * codecs became @stigmer/temporal-codecs.
9
+ */
10
+
11
+ import { gzipSync, gunzipSync } from "node:zlib";
12
+
13
+ export function compress(data: Buffer): Buffer<ArrayBuffer> {
14
+ return gzipSync(data) as Buffer<ArrayBuffer>;
15
+ }
16
+
17
+ export function decompress(data: Buffer): Buffer<ArrayBuffer> {
18
+ return gunzipSync(data) as Buffer<ArrayBuffer>;
19
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Claim-check configuration, env-driven with an enabled-iff-configured
3
+ * gate (CLAIMCHECK_ENABLED) — unlike encryption, the offload threshold and
4
+ * key prefix are operational tuning, not secrets, so plain env reads are
5
+ * the whole policy.
6
+ *
7
+ * Moved from backend/services/runner/src/claimcheck/config.ts when the
8
+ * codecs became @stigmer/temporal-codecs.
9
+ */
10
+
11
+ export interface ClaimcheckConfig {
12
+ readonly enabled: boolean;
13
+ readonly thresholdBytes: number;
14
+ readonly compressionEnabled: boolean;
15
+ readonly keyPrefix: string;
16
+ }
17
+
18
+ const DEFAULT_THRESHOLD_BYTES = 128 * 1024; // 128KB
19
+
20
+ export function loadClaimcheckConfig(): ClaimcheckConfig {
21
+ return {
22
+ enabled: process.env.CLAIMCHECK_ENABLED === "true",
23
+ thresholdBytes: parseInt(
24
+ process.env.CLAIMCHECK_THRESHOLD_BYTES ?? String(DEFAULT_THRESHOLD_BYTES),
25
+ 10,
26
+ ),
27
+ compressionEnabled: process.env.CLAIMCHECK_COMPRESSION_ENABLED !== "false",
28
+ keyPrefix: process.env.CLAIMCHECK_KEY_PREFIX ?? "claimcheck/",
29
+ };
30
+ }