@stigmer/temporal-codecs 0.1.0-dev.20261017020314.g644422724c27
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/LICENSE +194 -0
- package/README.md +48 -0
- package/claimcheck/compressor.d.ts +12 -0
- package/claimcheck/compressor.d.ts.map +1 -0
- package/claimcheck/compressor.js +17 -0
- package/claimcheck/compressor.js.map +1 -0
- package/claimcheck/config.d.ts +17 -0
- package/claimcheck/config.d.ts.map +1 -0
- package/claimcheck/config.js +19 -0
- package/claimcheck/config.js.map +1 -0
- package/claimcheck/payload-codec.d.ts +29 -0
- package/claimcheck/payload-codec.d.ts.map +1 -0
- package/claimcheck/payload-codec.js +110 -0
- package/claimcheck/payload-codec.js.map +1 -0
- package/claimcheck/storage.d.ts +20 -0
- package/claimcheck/storage.d.ts.map +1 -0
- package/claimcheck/storage.js +2 -0
- package/claimcheck/storage.js.map +1 -0
- package/connection/config.d.ts +100 -0
- package/connection/config.d.ts.map +1 -0
- package/connection/config.js +171 -0
- package/connection/config.js.map +1 -0
- package/encryption/config.d.ts +100 -0
- package/encryption/config.d.ts.map +1 -0
- package/encryption/config.js +119 -0
- package/encryption/config.js.map +1 -0
- package/encryption/payload-codec.d.ts +60 -0
- package/encryption/payload-codec.d.ts.map +1 -0
- package/encryption/payload-codec.js +163 -0
- package/encryption/payload-codec.js.map +1 -0
- package/index.d.ts +32 -0
- package/index.d.ts.map +1 -0
- package/index.js +28 -0
- package/index.js.map +1 -0
- package/package.json +42 -0
- package/src/claimcheck/compressor.ts +19 -0
- package/src/claimcheck/config.ts +30 -0
- package/src/claimcheck/payload-codec.ts +144 -0
- package/src/claimcheck/storage.ts +19 -0
- package/src/connection/config.ts +217 -0
- package/src/encryption/config.ts +204 -0
- package/src/encryption/payload-codec.ts +186 -0
- package/src/index.ts +49 -0
package/index.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @stigmer/temporal-codecs — the Temporal plumbing Stigmer's TypeScript
|
|
3
|
+
* Temporal processes (the server and the runner) share: payload codecs,
|
|
4
|
+
* and the connection-security settings every Temporal connection reads
|
|
5
|
+
* (`connection/config.ts`). The encryption envelope is a cross-language
|
|
6
|
+
* wire contract (the Java decode-only codec in stigmer-cloud's
|
|
7
|
+
* temporal-starter must match it byte-for-byte, pinned by the fixture in
|
|
8
|
+
* src/__tests__/fixtures/), and the connection settings must mean the same
|
|
9
|
+
* on every connection, which is why both live in one library instead of
|
|
10
|
+
* per-consumer copies that could fork.
|
|
11
|
+
*
|
|
12
|
+
* This is the package's ONLY public boundary. Codec order at the consumer
|
|
13
|
+
* is a correctness property: install [encryption, claimcheck] so encode
|
|
14
|
+
* encrypts before relocating (object storage only ever sees ciphertext)
|
|
15
|
+
* and decode restores the blob before decrypting.
|
|
16
|
+
*
|
|
17
|
+
* Dependency policy: @temporalio/common and @temporalio/proto are pinned
|
|
18
|
+
* exact and identical to the runner's pins, bumped in lockstep with it —
|
|
19
|
+
* the runner's consumer-install gate (stigmer#786) fails any version mix
|
|
20
|
+
* because a split @temporalio/proto tree registers the core-sdk protobuf
|
|
21
|
+
* namespace twice and crashes worker init.
|
|
22
|
+
*/
|
|
23
|
+
export { EncryptionPayloadCodec } from "./encryption/payload-codec.js";
|
|
24
|
+
export { loadPayloadEncryptionConfig } from "./encryption/config.js";
|
|
25
|
+
export type { BootstrapKeyMaterial, EncryptionKey, PayloadEncryptionConfig, PayloadKeyResolver, SecretReader, } from "./encryption/config.js";
|
|
26
|
+
export { loadTemporalConnectionConfig, temporalConnectionEnv, TEMPORAL_API_KEY_ENV, TEMPORAL_CONNECTION_ENV_NAMES, TEMPORAL_TLS_CLIENT_KEY_DATA_ENV, } from "./connection/config.js";
|
|
27
|
+
export type { TemporalConnectionConfig, TemporalTls, } from "./connection/config.js";
|
|
28
|
+
export { ClaimcheckPayloadCodec } from "./claimcheck/payload-codec.js";
|
|
29
|
+
export { loadClaimcheckConfig } from "./claimcheck/config.js";
|
|
30
|
+
export type { ClaimcheckConfig } from "./claimcheck/config.js";
|
|
31
|
+
export type { ClaimcheckStorage } from "./claimcheck/storage.js";
|
|
32
|
+
//# sourceMappingURL=index.d.ts.map
|
package/index.d.ts.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,EAAE,sBAAsB,EAAE,MAAM,+BAA+B,CAAC;AACvE,OAAO,EAAE,2BAA2B,EAAE,MAAM,wBAAwB,CAAC;AACrE,YAAY,EACV,oBAAoB,EACpB,aAAa,EACb,uBAAuB,EACvB,kBAAkB,EAClB,YAAY,GACb,MAAM,wBAAwB,CAAC;AAEhC,OAAO,EACL,4BAA4B,EAC5B,qBAAqB,EACrB,oBAAoB,EACpB,6BAA6B,EAC7B,gCAAgC,GACjC,MAAM,wBAAwB,CAAC;AAChC,YAAY,EACV,wBAAwB,EACxB,WAAW,GACZ,MAAM,wBAAwB,CAAC;AAEhC,OAAO,EAAE,sBAAsB,EAAE,MAAM,+BAA+B,CAAC;AACvE,OAAO,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAC;AAC9D,YAAY,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC/D,YAAY,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC"}
|
package/index.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @stigmer/temporal-codecs — the Temporal plumbing Stigmer's TypeScript
|
|
3
|
+
* Temporal processes (the server and the runner) share: payload codecs,
|
|
4
|
+
* and the connection-security settings every Temporal connection reads
|
|
5
|
+
* (`connection/config.ts`). The encryption envelope is a cross-language
|
|
6
|
+
* wire contract (the Java decode-only codec in stigmer-cloud's
|
|
7
|
+
* temporal-starter must match it byte-for-byte, pinned by the fixture in
|
|
8
|
+
* src/__tests__/fixtures/), and the connection settings must mean the same
|
|
9
|
+
* on every connection, which is why both live in one library instead of
|
|
10
|
+
* per-consumer copies that could fork.
|
|
11
|
+
*
|
|
12
|
+
* This is the package's ONLY public boundary. Codec order at the consumer
|
|
13
|
+
* is a correctness property: install [encryption, claimcheck] so encode
|
|
14
|
+
* encrypts before relocating (object storage only ever sees ciphertext)
|
|
15
|
+
* and decode restores the blob before decrypting.
|
|
16
|
+
*
|
|
17
|
+
* Dependency policy: @temporalio/common and @temporalio/proto are pinned
|
|
18
|
+
* exact and identical to the runner's pins, bumped in lockstep with it —
|
|
19
|
+
* the runner's consumer-install gate (stigmer#786) fails any version mix
|
|
20
|
+
* because a split @temporalio/proto tree registers the core-sdk protobuf
|
|
21
|
+
* namespace twice and crashes worker init.
|
|
22
|
+
*/
|
|
23
|
+
export { EncryptionPayloadCodec } from "./encryption/payload-codec.js";
|
|
24
|
+
export { loadPayloadEncryptionConfig } from "./encryption/config.js";
|
|
25
|
+
export { loadTemporalConnectionConfig, temporalConnectionEnv, TEMPORAL_API_KEY_ENV, TEMPORAL_CONNECTION_ENV_NAMES, TEMPORAL_TLS_CLIENT_KEY_DATA_ENV, } from "./connection/config.js";
|
|
26
|
+
export { ClaimcheckPayloadCodec } from "./claimcheck/payload-codec.js";
|
|
27
|
+
export { loadClaimcheckConfig } from "./claimcheck/config.js";
|
|
28
|
+
//# sourceMappingURL=index.js.map
|
package/index.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,EAAE,sBAAsB,EAAE,MAAM,+BAA+B,CAAC;AACvE,OAAO,EAAE,2BAA2B,EAAE,MAAM,wBAAwB,CAAC;AASrE,OAAO,EACL,4BAA4B,EAC5B,qBAAqB,EACrB,oBAAoB,EACpB,6BAA6B,EAC7B,gCAAgC,GACjC,MAAM,wBAAwB,CAAC;AAMhC,OAAO,EAAE,sBAAsB,EAAE,MAAM,+BAA+B,CAAC;AACvE,OAAO,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stigmer/temporal-codecs",
|
|
3
|
+
"version": "0.1.0-dev.20261017020314.g644422724c27",
|
|
4
|
+
"description": "The Temporal plumbing Stigmer's TypeScript Temporal processes share — AES-256-GCM payload encryption and claim-check offloading (the one home for the cross-language envelope contract), and the connection-security settings every Temporal connection reads",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/stigmer/stigmer.git",
|
|
11
|
+
"directory": "backend/libs/ts/temporal-codecs"
|
|
12
|
+
},
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": "^22.13.0 || >=23.4.0"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"stigmer",
|
|
18
|
+
"temporal",
|
|
19
|
+
"payload-codec",
|
|
20
|
+
"encryption",
|
|
21
|
+
"claim-check",
|
|
22
|
+
"tls"
|
|
23
|
+
],
|
|
24
|
+
"main": "./index.js",
|
|
25
|
+
"types": "./index.d.ts",
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./index.d.ts",
|
|
29
|
+
"import": "./index.js",
|
|
30
|
+
"default": "./index.js"
|
|
31
|
+
},
|
|
32
|
+
"./connection": {
|
|
33
|
+
"types": "./connection/config.d.ts",
|
|
34
|
+
"import": "./connection/config.js",
|
|
35
|
+
"default": "./connection/config.js"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@temporalio/common": "1.16.2",
|
|
40
|
+
"@temporalio/proto": "1.16.2"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Temporal PayloadCodec that transparently offloads large payloads to
|
|
3
|
+
* external storage ({@link ClaimcheckStorage}). Payloads below the
|
|
4
|
+
* threshold pass through unchanged. Payloads at or above the threshold
|
|
5
|
+
* are compressed (optional), uploaded, and replaced with a small
|
|
6
|
+
* reference marker.
|
|
7
|
+
*
|
|
8
|
+
* On decode, markers are detected, the original payload is downloaded
|
|
9
|
+
* and decompressed, and the original bytes are restored — transparent
|
|
10
|
+
* to workflow/activity code.
|
|
11
|
+
*
|
|
12
|
+
* Moved from backend/services/runner/src/claimcheck/payload-codec.ts when
|
|
13
|
+
* the codecs became @stigmer/temporal-codecs (one home for the
|
|
14
|
+
* cross-language envelope contract; the TS server is the second consumer).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { randomUUID } from "node:crypto";
|
|
18
|
+
import type { Payload, PayloadCodec } from "@temporalio/common";
|
|
19
|
+
import type { ClaimcheckStorage } from "./storage.js";
|
|
20
|
+
import type { ClaimcheckConfig } from "./config.js";
|
|
21
|
+
import { compress, decompress } from "./compressor.js";
|
|
22
|
+
|
|
23
|
+
const MARKER_METADATA_KEY = "encoding";
|
|
24
|
+
const MARKER_ENCODING_VALUE = "binary/claimcheck";
|
|
25
|
+
|
|
26
|
+
interface ClaimcheckMarker {
|
|
27
|
+
key: string;
|
|
28
|
+
size: number;
|
|
29
|
+
compressed: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* The relocated payload's original metadata, base64-encoded per value.
|
|
32
|
+
* Without it the restored payload would carry the marker's own
|
|
33
|
+
* "binary/claimcheck" encoding and no payload converter could interpret
|
|
34
|
+
* it. Absent on markers written before this field existed; those decode
|
|
35
|
+
* with the legacy (metadata-less) behavior.
|
|
36
|
+
*/
|
|
37
|
+
metadata?: Record<string, string>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class ClaimcheckPayloadCodec implements PayloadCodec {
|
|
41
|
+
constructor(
|
|
42
|
+
private readonly storage: ClaimcheckStorage,
|
|
43
|
+
private readonly config: ClaimcheckConfig,
|
|
44
|
+
) {}
|
|
45
|
+
|
|
46
|
+
async encode(payloads: Payload[]): Promise<Payload[]> {
|
|
47
|
+
return Promise.all(payloads.map((p) => this.encodePayload(p)));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async decode(payloads: Payload[]): Promise<Payload[]> {
|
|
51
|
+
return Promise.all(payloads.map((p) => this.decodePayload(p)));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private async encodePayload(payload: Payload): Promise<Payload> {
|
|
55
|
+
const data = payload.data;
|
|
56
|
+
if (!data || data.length < this.config.thresholdBytes) {
|
|
57
|
+
return payload;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const originalBuf = Buffer.from(data);
|
|
61
|
+
let uploadBuf = originalBuf;
|
|
62
|
+
let compressed = false;
|
|
63
|
+
|
|
64
|
+
if (this.config.compressionEnabled) {
|
|
65
|
+
const compressedBuf = compress(originalBuf);
|
|
66
|
+
if (compressedBuf.length < originalBuf.length) {
|
|
67
|
+
uploadBuf = compressedBuf;
|
|
68
|
+
compressed = true;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const key = `${this.config.keyPrefix}${randomUUID()}`;
|
|
73
|
+
await this.storage.upload(key, uploadBuf, "application/octet-stream");
|
|
74
|
+
|
|
75
|
+
const marker: ClaimcheckMarker = {
|
|
76
|
+
key,
|
|
77
|
+
size: data.length,
|
|
78
|
+
compressed,
|
|
79
|
+
metadata: serializeMetadata(payload.metadata),
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
metadata: {
|
|
84
|
+
[MARKER_METADATA_KEY]: Buffer.from(MARKER_ENCODING_VALUE),
|
|
85
|
+
},
|
|
86
|
+
data: Buffer.from(JSON.stringify(marker)),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private async decodePayload(payload: Payload): Promise<Payload> {
|
|
91
|
+
if (!this.isClaimcheckPayload(payload)) {
|
|
92
|
+
return payload;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const marker: ClaimcheckMarker = JSON.parse(
|
|
96
|
+
Buffer.from(payload.data!).toString("utf-8"),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
let rawBuf: Buffer;
|
|
100
|
+
try {
|
|
101
|
+
rawBuf = await this.storage.download(marker.key);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
// Preserve the claimcheck-scoped error contract; the shared download error
|
|
104
|
+
// already carries the HTTP status (proxy) or the miss (local) as the cause.
|
|
105
|
+
const cause = err instanceof Error ? err.message : String(err);
|
|
106
|
+
throw new Error(`Claimcheck retrieve failed for key ${marker.key}: ${cause}`);
|
|
107
|
+
}
|
|
108
|
+
const dataBuf = marker.compressed ? decompress(rawBuf) : rawBuf;
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
metadata: marker.metadata
|
|
112
|
+
? deserializeMetadata(marker.metadata)
|
|
113
|
+
: payload.metadata,
|
|
114
|
+
data: dataBuf,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private isClaimcheckPayload(payload: Payload): boolean {
|
|
119
|
+
const encoding = payload.metadata?.[MARKER_METADATA_KEY];
|
|
120
|
+
if (!encoding) return false;
|
|
121
|
+
return Buffer.from(encoding).toString("utf-8") === MARKER_ENCODING_VALUE;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function serializeMetadata(
|
|
126
|
+
metadata: Payload["metadata"],
|
|
127
|
+
): Record<string, string> | undefined {
|
|
128
|
+
if (!metadata) return undefined;
|
|
129
|
+
const out: Record<string, string> = {};
|
|
130
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
131
|
+
if (value) out[key] = Buffer.from(value).toString("base64");
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function deserializeMetadata(
|
|
137
|
+
metadata: Record<string, string>,
|
|
138
|
+
): Record<string, Uint8Array> {
|
|
139
|
+
const out: Record<string, Uint8Array> = {};
|
|
140
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
141
|
+
out[key] = Buffer.from(value, "base64");
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The blob-store contract the claim-check codec needs — nothing more.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately minimal (upload + download by key) so any consumer's
|
|
5
|
+
* storage client satisfies it structurally: the runner's ArtifactStorage
|
|
6
|
+
* (a richer interface with presigned-URL minting) matches as-is, and the
|
|
7
|
+
* TS server brings its own. Widening this port widens the wire contract's
|
|
8
|
+
* dependency surface for every consumer — do not add methods the codec
|
|
9
|
+
* itself does not call.
|
|
10
|
+
*/
|
|
11
|
+
export interface ClaimcheckStorage {
|
|
12
|
+
/** Store `content` under `key`; resolves once the blob is durable. */
|
|
13
|
+
upload(key: string, content: Buffer, contentType?: string): Promise<string>;
|
|
14
|
+
/**
|
|
15
|
+
* Return the exact bytes stored under `key`, or throw a descriptive,
|
|
16
|
+
* key-scoped `Error` when the object is missing or the transport fails.
|
|
17
|
+
*/
|
|
18
|
+
download(key: string): Promise<Buffer>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Temporal connection security: how a Stigmer process authenticates to the
|
|
3
|
+
* Temporal frontend it dials. The server's client and worker connections
|
|
4
|
+
* and the runner's worker connection all read it, so it lives here, in the
|
|
5
|
+
* one library both processes share. Unset means today's plaintext
|
|
6
|
+
* connection, unchanged.
|
|
7
|
+
*
|
|
8
|
+
* The settings (each `_PATH` item has a `_DATA` twin holding the PEM text):
|
|
9
|
+
*
|
|
10
|
+
* STIGMER_TEMPORAL_API_KEY bearer key; implies TLS
|
|
11
|
+
* STIGMER_TEMPORAL_TLS "true" or "1": TLS with the
|
|
12
|
+
* system's trusted roots
|
|
13
|
+
* STIGMER_TEMPORAL_TLS_SERVER_NAME SNI / host-name override
|
|
14
|
+
* STIGMER_TEMPORAL_TLS_SERVER_CA_CERT_PATH the frontend's CA (implies TLS)
|
|
15
|
+
* STIGMER_TEMPORAL_TLS_CLIENT_CERT_PATH mutual TLS: the client's
|
|
16
|
+
* STIGMER_TEMPORAL_TLS_CLIENT_KEY_PATH certificate and key, together
|
|
17
|
+
*
|
|
18
|
+
* WHY Stigmer's own names, not Temporal's `TEMPORAL_API_KEY` and
|
|
19
|
+
* `TEMPORAL_TLS_*`: those belong to the user's own Temporal work. A local
|
|
20
|
+
* Stigmer (`stigmer up`) inherits the user's shell, and agents that build
|
|
21
|
+
* Temporal applications export `TEMPORAL_API_KEY` for their own namespaces.
|
|
22
|
+
* Reading it would present the user's key to Stigmer's own Temporal and,
|
|
23
|
+
* through the runner's secret custody, take it away from the agents that
|
|
24
|
+
* need it. The shape follows Temporal's so an operator recognises it.
|
|
25
|
+
*
|
|
26
|
+
* WHY not Temporal's reader (`@temporalio/envconfig`): at the SDK version
|
|
27
|
+
* every Stigmer package pins (1.16.2), it reads `TEMPORAL_TLS=true` as TLS
|
|
28
|
+
* disabled. Releases before 1.22.0 carry that inversion. It also ignores
|
|
29
|
+
* host-verification settings, and it reads the user's own Temporal profile
|
|
30
|
+
* file by default.
|
|
31
|
+
*
|
|
32
|
+
* Misconfiguration fails the boot, the posture of the payload-encryption
|
|
33
|
+
* config beside this module: an operator who set half a client pair, or
|
|
34
|
+
* the same item twice, believes the connection is authenticated, and a
|
|
35
|
+
* silently weaker connection would be a security failure. An empty value
|
|
36
|
+
* means unset, so a blank line in a deployment's environment turns nothing
|
|
37
|
+
* on.
|
|
38
|
+
*
|
|
39
|
+
* Values are read through an injected {@link SecretReader}, as the
|
|
40
|
+
* encryption keys are: secret custody is the consumer's policy. The runner
|
|
41
|
+
* passes its credential store's reader, so the API key and the client key
|
|
42
|
+
* never live in an environment its agent tools can read.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { readFileSync } from "node:fs";
|
|
46
|
+
|
|
47
|
+
import type { SecretReader } from "../encryption/config.js";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* TLS settings in the Temporal SDK's own shape (`TLSConfig` in
|
|
51
|
+
* `@temporalio/common`), declared structurally so this library imports
|
|
52
|
+
* nothing internal. An empty object means TLS with the system's roots.
|
|
53
|
+
*/
|
|
54
|
+
export interface TemporalTls {
|
|
55
|
+
readonly serverNameOverride?: string;
|
|
56
|
+
readonly serverRootCACertificate?: Uint8Array;
|
|
57
|
+
readonly clientCertPair?: {
|
|
58
|
+
readonly crt: Uint8Array;
|
|
59
|
+
readonly key: Uint8Array;
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* What a connection needs beyond its address. Both fields are absent for a
|
|
65
|
+
* plaintext connection; spread it into `Connection.connect` or
|
|
66
|
+
* `NativeConnection.connect` as is.
|
|
67
|
+
*/
|
|
68
|
+
export interface TemporalConnectionConfig {
|
|
69
|
+
readonly tls?: TemporalTls;
|
|
70
|
+
readonly apiKey?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const TEMPORAL_API_KEY_ENV = "STIGMER_TEMPORAL_API_KEY";
|
|
74
|
+
export const TEMPORAL_TLS_ENV = "STIGMER_TEMPORAL_TLS";
|
|
75
|
+
export const TEMPORAL_TLS_SERVER_NAME_ENV = "STIGMER_TEMPORAL_TLS_SERVER_NAME";
|
|
76
|
+
|
|
77
|
+
/** The PEM items, each settable as a file path or as its text. */
|
|
78
|
+
const PEM_ITEMS = {
|
|
79
|
+
serverCa: "STIGMER_TEMPORAL_TLS_SERVER_CA_CERT",
|
|
80
|
+
clientCert: "STIGMER_TEMPORAL_TLS_CLIENT_CERT",
|
|
81
|
+
clientKey: "STIGMER_TEMPORAL_TLS_CLIENT_KEY",
|
|
82
|
+
} as const;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The `_DATA` name of the client key: a credential, which the runner takes
|
|
86
|
+
* into custody beside the API key (its `_PATH` twin names a file, not a
|
|
87
|
+
* secret).
|
|
88
|
+
*/
|
|
89
|
+
export const TEMPORAL_TLS_CLIENT_KEY_DATA_ENV = `${PEM_ITEMS.clientKey}_DATA`;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Every setting name this module reads, exactly. A process that hands its
|
|
93
|
+
* children their own rendered settings ({@link temporalConnectionEnv})
|
|
94
|
+
* removes these from what the child inherits; other `STIGMER_TEMPORAL_*`
|
|
95
|
+
* names (the CLI's own) are not connection settings.
|
|
96
|
+
*/
|
|
97
|
+
export const TEMPORAL_CONNECTION_ENV_NAMES: readonly string[] = [
|
|
98
|
+
TEMPORAL_API_KEY_ENV,
|
|
99
|
+
TEMPORAL_TLS_ENV,
|
|
100
|
+
TEMPORAL_TLS_SERVER_NAME_ENV,
|
|
101
|
+
...Object.values(PEM_ITEMS).flatMap((item) => [`${item}_PATH`, `${item}_DATA`]),
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Loads the connection settings.
|
|
106
|
+
*
|
|
107
|
+
* @throws when a setting is contradictory or unreadable: a client
|
|
108
|
+
* certificate without its key (or the reverse), both the `_PATH` and the
|
|
109
|
+
* `_DATA` form of one item, a path that cannot be read, or a
|
|
110
|
+
* `STIGMER_TEMPORAL_TLS` that is neither true nor false.
|
|
111
|
+
*/
|
|
112
|
+
export function loadTemporalConnectionConfig(
|
|
113
|
+
read: SecretReader,
|
|
114
|
+
): TemporalConnectionConfig {
|
|
115
|
+
const value = (name: string): string | undefined => {
|
|
116
|
+
const raw = read(name);
|
|
117
|
+
return raw === undefined || raw.trim() === "" ? undefined : raw;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const apiKey = value(TEMPORAL_API_KEY_ENV)?.trim();
|
|
121
|
+
const tlsFlag = parseFlag(value(TEMPORAL_TLS_ENV));
|
|
122
|
+
const serverName = value(TEMPORAL_TLS_SERVER_NAME_ENV)?.trim();
|
|
123
|
+
const serverCa = readPem(PEM_ITEMS.serverCa, value);
|
|
124
|
+
const clientCert = readPem(PEM_ITEMS.clientCert, value);
|
|
125
|
+
const clientKey = readPem(PEM_ITEMS.clientKey, value);
|
|
126
|
+
|
|
127
|
+
if ((clientCert === undefined) !== (clientKey === undefined)) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`Temporal connection misconfigured: mutual TLS needs both ` +
|
|
130
|
+
`${PEM_ITEMS.clientCert}_* and ${PEM_ITEMS.clientKey}_* — set both or neither`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const tlsRequested =
|
|
135
|
+
tlsFlag === true ||
|
|
136
|
+
apiKey !== undefined ||
|
|
137
|
+
serverName !== undefined ||
|
|
138
|
+
serverCa !== undefined ||
|
|
139
|
+
clientCert !== undefined;
|
|
140
|
+
if (tlsFlag === false && tlsRequested) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`Temporal connection misconfigured: ${TEMPORAL_TLS_ENV} is false while ` +
|
|
143
|
+
`an API key, a server name, a CA or a client certificate is set — ` +
|
|
144
|
+
`each of those needs TLS`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
if (!tlsRequested) return {};
|
|
148
|
+
|
|
149
|
+
const tls: TemporalTls = {
|
|
150
|
+
...(serverName !== undefined ? { serverNameOverride: serverName } : {}),
|
|
151
|
+
...(serverCa !== undefined ? { serverRootCACertificate: serverCa } : {}),
|
|
152
|
+
...(clientCert !== undefined && clientKey !== undefined
|
|
153
|
+
? { clientCertPair: { crt: clientCert, key: clientKey } }
|
|
154
|
+
: {}),
|
|
155
|
+
};
|
|
156
|
+
return { tls, ...(apiKey !== undefined ? { apiKey } : {}) };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Renders loaded settings back as the environment a child runner reads,
|
|
161
|
+
* every PEM item in its `_DATA` form, so that no file has to exist where
|
|
162
|
+
* the child runs. The server's sandbox drivers pass this to the runners
|
|
163
|
+
* they start. Round-trips: loading the rendered environment yields the
|
|
164
|
+
* same settings.
|
|
165
|
+
*/
|
|
166
|
+
export function temporalConnectionEnv(
|
|
167
|
+
config: TemporalConnectionConfig,
|
|
168
|
+
): Record<string, string> {
|
|
169
|
+
const env: Record<string, string> = {};
|
|
170
|
+
if (config.tls === undefined) return env;
|
|
171
|
+
env[TEMPORAL_TLS_ENV] = "true";
|
|
172
|
+
const text = (bytes: Uint8Array): string => Buffer.from(bytes).toString("utf8");
|
|
173
|
+
const { serverNameOverride, serverRootCACertificate, clientCertPair } = config.tls;
|
|
174
|
+
if (serverNameOverride !== undefined) env[TEMPORAL_TLS_SERVER_NAME_ENV] = serverNameOverride;
|
|
175
|
+
if (serverRootCACertificate !== undefined) {
|
|
176
|
+
env[`${PEM_ITEMS.serverCa}_DATA`] = text(serverRootCACertificate);
|
|
177
|
+
}
|
|
178
|
+
if (clientCertPair !== undefined) {
|
|
179
|
+
env[`${PEM_ITEMS.clientCert}_DATA`] = text(clientCertPair.crt);
|
|
180
|
+
env[TEMPORAL_TLS_CLIENT_KEY_DATA_ENV] = text(clientCertPair.key);
|
|
181
|
+
}
|
|
182
|
+
if (config.apiKey !== undefined) env[TEMPORAL_API_KEY_ENV] = config.apiKey;
|
|
183
|
+
return env;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function parseFlag(raw: string | undefined): boolean | undefined {
|
|
187
|
+
if (raw === undefined) return undefined;
|
|
188
|
+
const flag = raw.trim().toLowerCase();
|
|
189
|
+
if (flag === "true" || flag === "1") return true;
|
|
190
|
+
if (flag === "false" || flag === "0") return false;
|
|
191
|
+
throw new Error(
|
|
192
|
+
`Temporal connection misconfigured: ${TEMPORAL_TLS_ENV} must be true, 1, false or 0, got "${raw}"`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function readPem(
|
|
197
|
+
item: string,
|
|
198
|
+
value: (name: string) => string | undefined,
|
|
199
|
+
): Uint8Array | undefined {
|
|
200
|
+
const path = value(`${item}_PATH`);
|
|
201
|
+
const data = value(`${item}_DATA`);
|
|
202
|
+
if (path !== undefined && data !== undefined) {
|
|
203
|
+
throw new Error(
|
|
204
|
+
`Temporal connection misconfigured: set ${item}_PATH or ${item}_DATA, not both`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
if (data !== undefined) return Buffer.from(data, "utf8");
|
|
208
|
+
if (path === undefined) return undefined;
|
|
209
|
+
try {
|
|
210
|
+
return readFileSync(path.trim());
|
|
211
|
+
} catch (err) {
|
|
212
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
213
|
+
throw new Error(
|
|
214
|
+
`Temporal connection misconfigured: ${item}_PATH "${path.trim()}" cannot be read (${reason})`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
}
|