@mondaydotcomorg/z2h-shared-utils 0.4.3 → 0.6.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.
Files changed (41) hide show
  1. package/dist/esm/index.d.ts +2 -1
  2. package/dist/esm/index.d.ts.map +1 -1
  3. package/dist/esm/index.mjs +2 -1
  4. package/dist/esm/integrations.d.ts +15 -0
  5. package/dist/esm/integrations.d.ts.map +1 -0
  6. package/dist/esm/integrations.mjs +14 -0
  7. package/dist/esm/observability/events.d.ts +1 -0
  8. package/dist/esm/observability/events.d.ts.map +1 -1
  9. package/dist/esm/observability/events.mjs +1 -0
  10. package/dist/esm/secrets/__tests__/secret-cipher.test.d.ts +2 -0
  11. package/dist/esm/secrets/__tests__/secret-cipher.test.d.ts.map +1 -0
  12. package/dist/esm/secrets/index.d.ts +2 -0
  13. package/dist/esm/secrets/index.d.ts.map +1 -0
  14. package/dist/esm/secrets/index.mjs +1 -0
  15. package/dist/esm/secrets/secret-cipher.d.ts +3 -0
  16. package/dist/esm/secrets/secret-cipher.d.ts.map +1 -0
  17. package/dist/esm/secrets/secret-cipher.mjs +99 -0
  18. package/dist/index.d.ts +2 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +10 -0
  21. package/dist/integrations.d.ts +15 -0
  22. package/dist/integrations.d.ts.map +1 -0
  23. package/dist/integrations.js +17 -0
  24. package/dist/observability/events.d.ts +1 -0
  25. package/dist/observability/events.d.ts.map +1 -1
  26. package/dist/observability/events.js +1 -0
  27. package/dist/secrets/__tests__/secret-cipher.test.d.ts +2 -0
  28. package/dist/secrets/__tests__/secret-cipher.test.d.ts.map +1 -0
  29. package/dist/secrets/index.d.ts +2 -0
  30. package/dist/secrets/index.d.ts.map +1 -0
  31. package/dist/secrets/index.js +8 -0
  32. package/dist/secrets/secret-cipher.d.ts +3 -0
  33. package/dist/secrets/secret-cipher.d.ts.map +1 -0
  34. package/dist/secrets/secret-cipher.js +102 -0
  35. package/package.json +1 -1
  36. package/src/index.ts +7 -1
  37. package/src/integrations.ts +15 -0
  38. package/src/observability/events.ts +1 -0
  39. package/src/secrets/__tests__/secret-cipher.test.ts +48 -0
  40. package/src/secrets/index.ts +1 -0
  41. package/src/secrets/secret-cipher.ts +104 -0
@@ -1,2 +1,3 @@
1
- export {};
1
+ export { ALWAYS_ON_CHANNELS, DECLARED_INTEGRATIONS, type AlwaysOnChannel, type DeclaredIntegration, } from './integrations';
2
+ export * from './secrets';
2
3
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,kBAAkB,EAClB,qBAAqB,EACrB,KAAK,eAAe,EACpB,KAAK,mBAAmB,GACzB,MAAM,gBAAgB,CAAC;AACxB,cAAc,WAAW,CAAC"}
@@ -1 +1,2 @@
1
-
1
+ export { ALWAYS_ON_CHANNELS, DECLARED_INTEGRATIONS } from './integrations.mjs';
2
+ export { decryptSecret, encryptSecret } from './secrets/secret-cipher.mjs';
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Backend channels surfaced as `ctx.api.v1.<name>`.
3
+ *
4
+ * Always-on channels need no OAuth and are never declared — the runner builds
5
+ * them for every app regardless of what its handlers touch.
6
+ */
7
+ export declare const ALWAYS_ON_CHANNELS: readonly ["db", "llm", "snowflake"];
8
+ export type AlwaysOnChannel = (typeof ALWAYS_ON_CHANNELS)[number];
9
+ /**
10
+ * Declared (OAuth-gated) integrations: closed set the CLI detects from handler
11
+ * sources and the broker validates on register/update.
12
+ */
13
+ export declare const DECLARED_INTEGRATIONS: readonly ["monday"];
14
+ export type DeclaredIntegration = (typeof DECLARED_INTEGRATIONS)[number];
15
+ //# sourceMappingURL=integrations.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"integrations.d.ts","sourceRoot":"","sources":["../../src/integrations.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,qCAAsC,CAAC;AACtE,MAAM,MAAM,eAAe,GAAG,CAAC,OAAO,kBAAkB,CAAC,CAAC,MAAM,CAAC,CAAC;AAElE;;;GAGG;AACH,eAAO,MAAM,qBAAqB,qBAAsB,CAAC;AACzD,MAAM,MAAM,mBAAmB,GAAG,CAAC,OAAO,qBAAqB,CAAC,CAAC,MAAM,CAAC,CAAC"}
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Backend channels surfaced as `ctx.api.v1.<name>`.
3
+ *
4
+ * Always-on channels need no OAuth and are never declared — the runner builds
5
+ * them for every app regardless of what its handlers touch.
6
+ */
7
+ const ALWAYS_ON_CHANNELS = ['db', 'llm', 'snowflake'];
8
+ /**
9
+ * Declared (OAuth-gated) integrations: closed set the CLI detects from handler
10
+ * sources and the broker validates on register/update.
11
+ */
12
+ const DECLARED_INTEGRATIONS = ['monday'];
13
+
14
+ export { ALWAYS_ON_CHANNELS, DECLARED_INTEGRATIONS };
@@ -72,5 +72,6 @@ export declare const Z2hAgentEvents: {
72
72
  /** BI events tracked in `z2h-cli`. */
73
73
  export declare const Z2hCliEvents: {
74
74
  readonly error: "z2h_cli_error";
75
+ readonly handlerParseError: "z2h_cli_handler_parse_error";
75
76
  };
76
77
  //# sourceMappingURL=events.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"events.d.ts","sourceRoot":"","sources":["../../../src/observability/events.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiCtB,CAAC;AAEX,iEAAiE;AACjE,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;CAmBnB,CAAC;AAEX,oDAAoD;AACpD,eAAO,MAAM,cAAc;;;;;;;;CAQjB,CAAC;AAEX,sCAAsC;AACtC,eAAO,MAAM,YAAY;;CAEf,CAAC"}
1
+ {"version":3,"file":"events.d.ts","sourceRoot":"","sources":["../../../src/observability/events.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiCtB,CAAC;AAEX,iEAAiE;AACjE,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;CAmBnB,CAAC;AAEX,oDAAoD;AACpD,eAAO,MAAM,cAAc;;;;;;;;CAQjB,CAAC;AAEX,sCAAsC;AACtC,eAAO,MAAM,YAAY;;;CAGf,CAAC"}
@@ -72,6 +72,7 @@ const Z2hAgentEvents = {
72
72
  /** BI events tracked in `z2h-cli`. */
73
73
  const Z2hCliEvents = {
74
74
  error: 'z2h_cli_error',
75
+ handlerParseError: 'z2h_cli_handler_parse_error',
75
76
  };
76
77
 
77
78
  export { BackendRunnerEvents, Z2hAgentEvents, Z2hCliEvents, Z2hServiceEvents };
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=secret-cipher.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"secret-cipher.test.d.ts","sourceRoot":"","sources":["../../../../src/secrets/__tests__/secret-cipher.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,2 @@
1
+ export * from './secret-cipher';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/secrets/index.ts"],"names":[],"mappings":"AAAA,cAAc,iBAAiB,CAAC"}
@@ -0,0 +1 @@
1
+ export { decryptSecret, encryptSecret } from './secret-cipher.mjs';
@@ -0,0 +1,3 @@
1
+ export declare function encryptSecret(plaintext: string): string;
2
+ export declare function decryptSecret(blob: string): string;
3
+ //# sourceMappingURL=secret-cipher.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"secret-cipher.d.ts","sourceRoot":"","sources":["../../../src/secrets/secret-cipher.ts"],"names":[],"mappings":"AAqEA,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAavD;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAmBlD"}
@@ -0,0 +1,99 @@
1
+ import { randomBytes, createCipheriv, createDecipheriv, createHash, scryptSync } from 'node:crypto';
2
+ import { Api } from '@mondaydotcomorg/trident-backend-api';
3
+
4
+ /**
5
+ * Symmetric encryption for secrets at rest. AES-256-GCM with a key derived
6
+ * (via scrypt, salted per blob) from the `z2h_secrets_key` secret. The `v1:`
7
+ * prefix leaves room to rotate the scheme without breaking stored blobs.
8
+ * Decryption happens server-side only, and a decrypted value never crosses
9
+ * into an isolate.
10
+ *
11
+ * Shared between `bigbrain-zth` (encrypts on write) and `zth-backend-runner`
12
+ * (decrypts on use) — both must read the *same* `z2h_secrets_key` value, so
13
+ * this must stay byte-identical across the two callers.
14
+ */
15
+ /** Config-variables key (how mm + the cluster inject it), stored as {"key":"…"}. */
16
+ const KEY_CONFIG = 'z2h_secrets_key';
17
+ const CURRENT_VERSION = 'v1';
18
+ const SALT_LENGTH = 16;
19
+ const SCRYPT_KEY_LENGTH = 32;
20
+ /** Full-length GCM tag. Enforced on decrypt so a truncated tag can't weaken the
21
+ * forgery margin (a short tag shrinks it from 2^128 toward 2^32). */
22
+ const GCM_TAG_LENGTH = 16;
23
+ /** Bounds the derived-key cache below — one entry per distinct (passphrase,
24
+ * salt) pair ever seen, so a long-lived process can't grow this unbounded. */
25
+ const MAX_CACHED_KEYS = 256;
26
+ /**
27
+ * The passphrase from the trident `configurationVariables` secret, a
28
+ * `{ key: "…" }` object.
29
+ */
30
+ function passphraseSource() {
31
+ const cfg = Api.getPart('configurationVariables')?.get(KEY_CONFIG);
32
+ return cfg?.key || undefined;
33
+ }
34
+ function passphrase() {
35
+ const configured = passphraseSource();
36
+ if (!configured) {
37
+ throw new Error(`${KEY_CONFIG} must be set`);
38
+ }
39
+ return configured;
40
+ }
41
+ // Each blob carries its own random salt (see encryptSecret), so the key derived
42
+ // for it is cached by (passphrase, salt) rather than by passphrase alone — a
43
+ // repeated read of the same still-live blob skips re-running scrypt.
44
+ const derivedKeyCache = new Map();
45
+ function deriveKey(source, salt) {
46
+ // Hash the passphrase for the cache key so the plaintext secret isn't held
47
+ // in memory for the cache's lifetime (rather than just for this call).
48
+ const sourceDigest = createHash('sha256').update(source).digest('base64');
49
+ const cacheKey = `${sourceDigest}:${salt.toString('base64')}`;
50
+ const cached = derivedKeyCache.get(cacheKey);
51
+ if (cached) {
52
+ return cached;
53
+ }
54
+ const derived = scryptSync(source, salt, SCRYPT_KEY_LENGTH);
55
+ if (derivedKeyCache.size >= MAX_CACHED_KEYS) {
56
+ const oldest = derivedKeyCache.keys().next().value;
57
+ if (oldest !== undefined) {
58
+ derivedKeyCache.delete(oldest);
59
+ }
60
+ }
61
+ derivedKeyCache.set(cacheKey, derived);
62
+ return derived;
63
+ }
64
+ function encryptSecret(plaintext) {
65
+ const salt = randomBytes(SALT_LENGTH);
66
+ const iv = randomBytes(12);
67
+ const cipher = createCipheriv('aes-256-gcm', deriveKey(passphrase(), salt), iv);
68
+ const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
69
+ const tag = cipher.getAuthTag();
70
+ return [
71
+ CURRENT_VERSION,
72
+ iv.toString('base64'),
73
+ salt.toString('base64'),
74
+ tag.toString('base64'),
75
+ enc.toString('base64'),
76
+ ].join(':');
77
+ }
78
+ function decryptSecret(blob) {
79
+ const parts = blob.split(':');
80
+ if (parts.length !== 5) {
81
+ throw new Error('unrecognized secret format');
82
+ }
83
+ const [version, ivB64, saltB64, tagB64, dataB64] = parts;
84
+ if (version !== CURRENT_VERSION || !ivB64 || !saltB64 || !tagB64 || dataB64 === undefined) {
85
+ throw new Error('unrecognized secret format');
86
+ }
87
+ const tag = Buffer.from(tagB64, 'base64');
88
+ if (tag.length !== GCM_TAG_LENGTH) {
89
+ throw new Error('unrecognized secret format');
90
+ }
91
+ const key = deriveKey(passphrase(), Buffer.from(saltB64, 'base64'));
92
+ const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(ivB64, 'base64'), {
93
+ authTagLength: GCM_TAG_LENGTH,
94
+ });
95
+ decipher.setAuthTag(tag);
96
+ return Buffer.concat([decipher.update(Buffer.from(dataB64, 'base64')), decipher.final()]).toString('utf8');
97
+ }
98
+
99
+ export { decryptSecret, encryptSecret };
package/dist/index.d.ts CHANGED
@@ -1,2 +1,3 @@
1
- export {};
1
+ export { ALWAYS_ON_CHANNELS, DECLARED_INTEGRATIONS, type AlwaysOnChannel, type DeclaredIntegration, } from './integrations';
2
+ export * from './secrets';
2
3
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,kBAAkB,EAClB,qBAAqB,EACrB,KAAK,eAAe,EACpB,KAAK,mBAAmB,GACzB,MAAM,gBAAgB,CAAC;AACxB,cAAc,WAAW,CAAC"}
package/dist/index.js CHANGED
@@ -1 +1,11 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
1
2
 
3
+ const integrations = require('./integrations.js');
4
+ const secrets_secretCipher = require('./secrets/secret-cipher.js');
5
+
6
+
7
+
8
+ exports.ALWAYS_ON_CHANNELS = integrations.ALWAYS_ON_CHANNELS;
9
+ exports.DECLARED_INTEGRATIONS = integrations.DECLARED_INTEGRATIONS;
10
+ exports.decryptSecret = secrets_secretCipher.decryptSecret;
11
+ exports.encryptSecret = secrets_secretCipher.encryptSecret;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Backend channels surfaced as `ctx.api.v1.<name>`.
3
+ *
4
+ * Always-on channels need no OAuth and are never declared — the runner builds
5
+ * them for every app regardless of what its handlers touch.
6
+ */
7
+ export declare const ALWAYS_ON_CHANNELS: readonly ["db", "llm", "snowflake"];
8
+ export type AlwaysOnChannel = (typeof ALWAYS_ON_CHANNELS)[number];
9
+ /**
10
+ * Declared (OAuth-gated) integrations: closed set the CLI detects from handler
11
+ * sources and the broker validates on register/update.
12
+ */
13
+ export declare const DECLARED_INTEGRATIONS: readonly ["monday"];
14
+ export type DeclaredIntegration = (typeof DECLARED_INTEGRATIONS)[number];
15
+ //# sourceMappingURL=integrations.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"integrations.d.ts","sourceRoot":"","sources":["../src/integrations.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,qCAAsC,CAAC;AACtE,MAAM,MAAM,eAAe,GAAG,CAAC,OAAO,kBAAkB,CAAC,CAAC,MAAM,CAAC,CAAC;AAElE;;;GAGG;AACH,eAAO,MAAM,qBAAqB,qBAAsB,CAAC;AACzD,MAAM,MAAM,mBAAmB,GAAG,CAAC,OAAO,qBAAqB,CAAC,CAAC,MAAM,CAAC,CAAC"}
@@ -0,0 +1,17 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
2
+
3
+ /**
4
+ * Backend channels surfaced as `ctx.api.v1.<name>`.
5
+ *
6
+ * Always-on channels need no OAuth and are never declared — the runner builds
7
+ * them for every app regardless of what its handlers touch.
8
+ */
9
+ const ALWAYS_ON_CHANNELS = ['db', 'llm', 'snowflake'];
10
+ /**
11
+ * Declared (OAuth-gated) integrations: closed set the CLI detects from handler
12
+ * sources and the broker validates on register/update.
13
+ */
14
+ const DECLARED_INTEGRATIONS = ['monday'];
15
+
16
+ exports.ALWAYS_ON_CHANNELS = ALWAYS_ON_CHANNELS;
17
+ exports.DECLARED_INTEGRATIONS = DECLARED_INTEGRATIONS;
@@ -72,5 +72,6 @@ export declare const Z2hAgentEvents: {
72
72
  /** BI events tracked in `z2h-cli`. */
73
73
  export declare const Z2hCliEvents: {
74
74
  readonly error: "z2h_cli_error";
75
+ readonly handlerParseError: "z2h_cli_handler_parse_error";
75
76
  };
76
77
  //# sourceMappingURL=events.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"events.d.ts","sourceRoot":"","sources":["../../src/observability/events.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiCtB,CAAC;AAEX,iEAAiE;AACjE,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;CAmBnB,CAAC;AAEX,oDAAoD;AACpD,eAAO,MAAM,cAAc;;;;;;;;CAQjB,CAAC;AAEX,sCAAsC;AACtC,eAAO,MAAM,YAAY;;CAEf,CAAC"}
1
+ {"version":3,"file":"events.d.ts","sourceRoot":"","sources":["../../src/observability/events.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiCtB,CAAC;AAEX,iEAAiE;AACjE,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;CAmBnB,CAAC;AAEX,oDAAoD;AACpD,eAAO,MAAM,cAAc;;;;;;;;CAQjB,CAAC;AAEX,sCAAsC;AACtC,eAAO,MAAM,YAAY;;;CAGf,CAAC"}
@@ -74,6 +74,7 @@ const Z2hAgentEvents = {
74
74
  /** BI events tracked in `z2h-cli`. */
75
75
  const Z2hCliEvents = {
76
76
  error: 'z2h_cli_error',
77
+ handlerParseError: 'z2h_cli_handler_parse_error',
77
78
  };
78
79
 
79
80
  exports.BackendRunnerEvents = BackendRunnerEvents;
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=secret-cipher.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"secret-cipher.test.d.ts","sourceRoot":"","sources":["../../../src/secrets/__tests__/secret-cipher.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,2 @@
1
+ export * from './secret-cipher';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/secrets/index.ts"],"names":[],"mappings":"AAAA,cAAc,iBAAiB,CAAC"}
@@ -0,0 +1,8 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
2
+
3
+ const secrets_secretCipher = require('./secret-cipher.js');
4
+
5
+
6
+
7
+ exports.decryptSecret = secrets_secretCipher.decryptSecret;
8
+ exports.encryptSecret = secrets_secretCipher.encryptSecret;
@@ -0,0 +1,3 @@
1
+ export declare function encryptSecret(plaintext: string): string;
2
+ export declare function decryptSecret(blob: string): string;
3
+ //# sourceMappingURL=secret-cipher.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"secret-cipher.d.ts","sourceRoot":"","sources":["../../src/secrets/secret-cipher.ts"],"names":[],"mappings":"AAqEA,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAavD;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAmBlD"}
@@ -0,0 +1,102 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
2
+
3
+ const node_crypto = require('node:crypto');
4
+ const tridentBackendApi = require('@mondaydotcomorg/trident-backend-api');
5
+
6
+ /**
7
+ * Symmetric encryption for secrets at rest. AES-256-GCM with a key derived
8
+ * (via scrypt, salted per blob) from the `z2h_secrets_key` secret. The `v1:`
9
+ * prefix leaves room to rotate the scheme without breaking stored blobs.
10
+ * Decryption happens server-side only, and a decrypted value never crosses
11
+ * into an isolate.
12
+ *
13
+ * Shared between `bigbrain-zth` (encrypts on write) and `zth-backend-runner`
14
+ * (decrypts on use) — both must read the *same* `z2h_secrets_key` value, so
15
+ * this must stay byte-identical across the two callers.
16
+ */
17
+ /** Config-variables key (how mm + the cluster inject it), stored as {"key":"…"}. */
18
+ const KEY_CONFIG = 'z2h_secrets_key';
19
+ const CURRENT_VERSION = 'v1';
20
+ const SALT_LENGTH = 16;
21
+ const SCRYPT_KEY_LENGTH = 32;
22
+ /** Full-length GCM tag. Enforced on decrypt so a truncated tag can't weaken the
23
+ * forgery margin (a short tag shrinks it from 2^128 toward 2^32). */
24
+ const GCM_TAG_LENGTH = 16;
25
+ /** Bounds the derived-key cache below — one entry per distinct (passphrase,
26
+ * salt) pair ever seen, so a long-lived process can't grow this unbounded. */
27
+ const MAX_CACHED_KEYS = 256;
28
+ /**
29
+ * The passphrase from the trident `configurationVariables` secret, a
30
+ * `{ key: "…" }` object.
31
+ */
32
+ function passphraseSource() {
33
+ const cfg = tridentBackendApi.Api.getPart('configurationVariables')?.get(KEY_CONFIG);
34
+ return cfg?.key || undefined;
35
+ }
36
+ function passphrase() {
37
+ const configured = passphraseSource();
38
+ if (!configured) {
39
+ throw new Error(`${KEY_CONFIG} must be set`);
40
+ }
41
+ return configured;
42
+ }
43
+ // Each blob carries its own random salt (see encryptSecret), so the key derived
44
+ // for it is cached by (passphrase, salt) rather than by passphrase alone — a
45
+ // repeated read of the same still-live blob skips re-running scrypt.
46
+ const derivedKeyCache = new Map();
47
+ function deriveKey(source, salt) {
48
+ // Hash the passphrase for the cache key so the plaintext secret isn't held
49
+ // in memory for the cache's lifetime (rather than just for this call).
50
+ const sourceDigest = node_crypto.createHash('sha256').update(source).digest('base64');
51
+ const cacheKey = `${sourceDigest}:${salt.toString('base64')}`;
52
+ const cached = derivedKeyCache.get(cacheKey);
53
+ if (cached) {
54
+ return cached;
55
+ }
56
+ const derived = node_crypto.scryptSync(source, salt, SCRYPT_KEY_LENGTH);
57
+ if (derivedKeyCache.size >= MAX_CACHED_KEYS) {
58
+ const oldest = derivedKeyCache.keys().next().value;
59
+ if (oldest !== undefined) {
60
+ derivedKeyCache.delete(oldest);
61
+ }
62
+ }
63
+ derivedKeyCache.set(cacheKey, derived);
64
+ return derived;
65
+ }
66
+ function encryptSecret(plaintext) {
67
+ const salt = node_crypto.randomBytes(SALT_LENGTH);
68
+ const iv = node_crypto.randomBytes(12);
69
+ const cipher = node_crypto.createCipheriv('aes-256-gcm', deriveKey(passphrase(), salt), iv);
70
+ const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
71
+ const tag = cipher.getAuthTag();
72
+ return [
73
+ CURRENT_VERSION,
74
+ iv.toString('base64'),
75
+ salt.toString('base64'),
76
+ tag.toString('base64'),
77
+ enc.toString('base64'),
78
+ ].join(':');
79
+ }
80
+ function decryptSecret(blob) {
81
+ const parts = blob.split(':');
82
+ if (parts.length !== 5) {
83
+ throw new Error('unrecognized secret format');
84
+ }
85
+ const [version, ivB64, saltB64, tagB64, dataB64] = parts;
86
+ if (version !== CURRENT_VERSION || !ivB64 || !saltB64 || !tagB64 || dataB64 === undefined) {
87
+ throw new Error('unrecognized secret format');
88
+ }
89
+ const tag = Buffer.from(tagB64, 'base64');
90
+ if (tag.length !== GCM_TAG_LENGTH) {
91
+ throw new Error('unrecognized secret format');
92
+ }
93
+ const key = deriveKey(passphrase(), Buffer.from(saltB64, 'base64'));
94
+ const decipher = node_crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(ivB64, 'base64'), {
95
+ authTagLength: GCM_TAG_LENGTH,
96
+ });
97
+ decipher.setAuthTag(tag);
98
+ return Buffer.concat([decipher.update(Buffer.from(dataB64, 'base64')), decipher.final()]).toString('utf8');
99
+ }
100
+
101
+ exports.decryptSecret = decryptSecret;
102
+ exports.encryptSecret = encryptSecret;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mondaydotcomorg/z2h-shared-utils",
3
- "version": "0.4.3",
3
+ "version": "0.6.0",
4
4
  "main": "./dist/index.js",
5
5
  "types": "./dist/index.d.ts",
6
6
  "exports": {
package/src/index.ts CHANGED
@@ -1 +1,7 @@
1
- export {};
1
+ export {
2
+ ALWAYS_ON_CHANNELS,
3
+ DECLARED_INTEGRATIONS,
4
+ type AlwaysOnChannel,
5
+ type DeclaredIntegration,
6
+ } from './integrations';
7
+ export * from './secrets';
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Backend channels surfaced as `ctx.api.v1.<name>`.
3
+ *
4
+ * Always-on channels need no OAuth and are never declared — the runner builds
5
+ * them for every app regardless of what its handlers touch.
6
+ */
7
+ export const ALWAYS_ON_CHANNELS = ['db', 'llm', 'snowflake'] as const;
8
+ export type AlwaysOnChannel = (typeof ALWAYS_ON_CHANNELS)[number];
9
+
10
+ /**
11
+ * Declared (OAuth-gated) integrations: closed set the CLI detects from handler
12
+ * sources and the broker validates on register/update.
13
+ */
14
+ export const DECLARED_INTEGRATIONS = ['monday'] as const;
15
+ export type DeclaredIntegration = (typeof DECLARED_INTEGRATIONS)[number];
@@ -75,4 +75,5 @@ export const Z2hAgentEvents = {
75
75
  /** BI events tracked in `z2h-cli`. */
76
76
  export const Z2hCliEvents = {
77
77
  error: 'z2h_cli_error',
78
+ handlerParseError: 'z2h_cli_handler_parse_error',
78
79
  } as const;
@@ -0,0 +1,48 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import { decryptSecret, encryptSecret } from '../secret-cipher';
4
+
5
+ const getMock = vi.fn();
6
+
7
+ vi.mock('@mondaydotcomorg/trident-backend-api', () => ({
8
+ Api: { getPart: vi.fn(() => ({ get: getMock })) },
9
+ }));
10
+
11
+ describe('secret-cipher', () => {
12
+ beforeEach(() => {
13
+ getMock.mockReset();
14
+ getMock.mockReturnValue({ key: 'test-secrets-key' });
15
+ });
16
+
17
+ it('round-trips a plaintext value', () => {
18
+ const blob = encryptSecret('super-secret-pat');
19
+ expect(decryptSecret(blob)).toBe('super-secret-pat');
20
+ });
21
+
22
+ it('produces a different blob (different iv/salt) for the same plaintext each time', () => {
23
+ const a = encryptSecret('same-value');
24
+ const b = encryptSecret('same-value');
25
+ expect(a).not.toBe(b);
26
+ expect(decryptSecret(a)).toBe('same-value');
27
+ expect(decryptSecret(b)).toBe('same-value');
28
+ });
29
+
30
+ it('rejects a tampered blob (flipped byte fails GCM auth)', () => {
31
+ const blob = encryptSecret('super-secret-pat');
32
+ const parts = blob.split(':');
33
+ const dataBuf = Buffer.from(parts[4], 'base64');
34
+ dataBuf[0] ^= 0xff;
35
+ parts[4] = dataBuf.toString('base64');
36
+ expect(() => decryptSecret(parts.join(':'))).toThrow();
37
+ });
38
+
39
+ it('rejects an unrecognized blob format', () => {
40
+ expect(() => decryptSecret('not-a-real-blob')).toThrow('unrecognized secret format');
41
+ expect(() => decryptSecret('v2:a:b:c:d')).toThrow('unrecognized secret format');
42
+ });
43
+
44
+ it('throws when the passphrase is not configured', () => {
45
+ getMock.mockReturnValue(undefined);
46
+ expect(() => encryptSecret('x')).toThrow('z2h_secrets_key must be set');
47
+ });
48
+ });
@@ -0,0 +1 @@
1
+ export * from './secret-cipher';
@@ -0,0 +1,104 @@
1
+ import { createCipheriv, createDecipheriv, createHash, randomBytes, scryptSync } from 'node:crypto';
2
+ import { Api } from '@mondaydotcomorg/trident-backend-api';
3
+
4
+ /**
5
+ * Symmetric encryption for secrets at rest. AES-256-GCM with a key derived
6
+ * (via scrypt, salted per blob) from the `z2h_secrets_key` secret. The `v1:`
7
+ * prefix leaves room to rotate the scheme without breaking stored blobs.
8
+ * Decryption happens server-side only, and a decrypted value never crosses
9
+ * into an isolate.
10
+ *
11
+ * Shared between `bigbrain-zth` (encrypts on write) and `zth-backend-runner`
12
+ * (decrypts on use) — both must read the *same* `z2h_secrets_key` value, so
13
+ * this must stay byte-identical across the two callers.
14
+ */
15
+
16
+ /** Config-variables key (how mm + the cluster inject it), stored as {"key":"…"}. */
17
+ const KEY_CONFIG = 'z2h_secrets_key';
18
+ const CURRENT_VERSION = 'v1' as const;
19
+ const SALT_LENGTH = 16;
20
+ const SCRYPT_KEY_LENGTH = 32;
21
+ /** Full-length GCM tag. Enforced on decrypt so a truncated tag can't weaken the
22
+ * forgery margin (a short tag shrinks it from 2^128 toward 2^32). */
23
+ const GCM_TAG_LENGTH = 16;
24
+ /** Bounds the derived-key cache below — one entry per distinct (passphrase,
25
+ * salt) pair ever seen, so a long-lived process can't grow this unbounded. */
26
+ const MAX_CACHED_KEYS = 256;
27
+
28
+ /**
29
+ * The passphrase from the trident `configurationVariables` secret, a
30
+ * `{ key: "…" }` object.
31
+ */
32
+ function passphraseSource(): string | undefined {
33
+ const cfg = Api.getPart('configurationVariables')?.get(KEY_CONFIG) as { key?: string } | undefined;
34
+ return cfg?.key || undefined;
35
+ }
36
+
37
+ function passphrase(): string {
38
+ const configured = passphraseSource();
39
+ if (!configured) {
40
+ throw new Error(`${KEY_CONFIG} must be set`);
41
+ }
42
+ return configured;
43
+ }
44
+
45
+ // Each blob carries its own random salt (see encryptSecret), so the key derived
46
+ // for it is cached by (passphrase, salt) rather than by passphrase alone — a
47
+ // repeated read of the same still-live blob skips re-running scrypt.
48
+ const derivedKeyCache = new Map<string, Buffer>();
49
+
50
+ function deriveKey(source: string, salt: Buffer): Buffer {
51
+ // Hash the passphrase for the cache key so the plaintext secret isn't held
52
+ // in memory for the cache's lifetime (rather than just for this call).
53
+ const sourceDigest = createHash('sha256').update(source).digest('base64');
54
+ const cacheKey = `${sourceDigest}:${salt.toString('base64')}`;
55
+ const cached = derivedKeyCache.get(cacheKey);
56
+ if (cached) {
57
+ return cached;
58
+ }
59
+ const derived = scryptSync(source, salt, SCRYPT_KEY_LENGTH);
60
+ if (derivedKeyCache.size >= MAX_CACHED_KEYS) {
61
+ const oldest = derivedKeyCache.keys().next().value;
62
+ if (oldest !== undefined) {
63
+ derivedKeyCache.delete(oldest);
64
+ }
65
+ }
66
+ derivedKeyCache.set(cacheKey, derived);
67
+ return derived;
68
+ }
69
+
70
+ export function encryptSecret(plaintext: string): string {
71
+ const salt = randomBytes(SALT_LENGTH);
72
+ const iv = randomBytes(12);
73
+ const cipher = createCipheriv('aes-256-gcm', deriveKey(passphrase(), salt), iv);
74
+ const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
75
+ const tag = cipher.getAuthTag();
76
+ return [
77
+ CURRENT_VERSION,
78
+ iv.toString('base64'),
79
+ salt.toString('base64'),
80
+ tag.toString('base64'),
81
+ enc.toString('base64'),
82
+ ].join(':');
83
+ }
84
+
85
+ export function decryptSecret(blob: string): string {
86
+ const parts = blob.split(':');
87
+ if (parts.length !== 5) {
88
+ throw new Error('unrecognized secret format');
89
+ }
90
+ const [version, ivB64, saltB64, tagB64, dataB64] = parts;
91
+ if (version !== CURRENT_VERSION || !ivB64 || !saltB64 || !tagB64 || dataB64 === undefined) {
92
+ throw new Error('unrecognized secret format');
93
+ }
94
+ const tag = Buffer.from(tagB64, 'base64');
95
+ if (tag.length !== GCM_TAG_LENGTH) {
96
+ throw new Error('unrecognized secret format');
97
+ }
98
+ const key = deriveKey(passphrase(), Buffer.from(saltB64, 'base64'));
99
+ const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(ivB64, 'base64'), {
100
+ authTagLength: GCM_TAG_LENGTH,
101
+ });
102
+ decipher.setAuthTag(tag);
103
+ return Buffer.concat([decipher.update(Buffer.from(dataB64, 'base64')), decipher.final()]).toString('utf8');
104
+ }