@sanoma-ai/workflows 0.10.0 → 0.10.2
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/CLOUD-IDENTITY.md +140 -0
- package/KMS-SECRETS.md +99 -0
- package/README.md +39 -7
- package/SECRET-SUPPORT.md +33 -0
- package/dist/apps.d.ts +6 -0
- package/dist/apps.js +18 -1
- package/dist/apps.js.map +1 -1
- package/dist/authentication.d.ts +35 -0
- package/dist/authentication.js +71 -1
- package/dist/authentication.js.map +1 -1
- package/dist/call.js +7 -5
- package/dist/call.js.map +1 -1
- package/dist/client.d.ts +54 -6
- package/dist/client.js +177 -5
- package/dist/client.js.map +1 -1
- package/dist/cloud-identity.d.ts +52 -0
- package/dist/cloud-identity.js +222 -0
- package/dist/cloud-identity.js.map +1 -0
- package/dist/config.d.ts +7 -4
- package/dist/config.js +44 -12
- package/dist/config.js.map +1 -1
- package/dist/connection.d.ts +223 -2
- package/dist/connection.js +340 -4
- package/dist/connection.js.map +1 -1
- package/dist/db.js +15 -2
- package/dist/db.js.map +1 -1
- package/dist/describe.d.ts +8 -0
- package/dist/describe.js +51 -1
- package/dist/describe.js.map +1 -1
- package/dist/direct.js +4 -0
- package/dist/direct.js.map +1 -1
- package/dist/fake.d.ts +13 -1
- package/dist/fake.js +70 -0
- package/dist/fake.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/kms-secrets.d.ts +24 -0
- package/dist/kms-secrets.js +209 -0
- package/dist/kms-secrets.js.map +1 -0
- package/dist/lint.js +1 -1
- package/dist/lint.js.map +1 -1
- package/dist/load.d.ts +11 -0
- package/dist/load.js +42 -8
- package/dist/load.js.map +1 -1
- package/dist/op.d.ts +14 -6
- package/dist/op.js +20 -2
- package/dist/op.js.map +1 -1
- package/dist/outline.d.ts +5 -0
- package/dist/outline.js +33 -1
- package/dist/outline.js.map +1 -1
- package/dist/paths.d.ts +2 -4
- package/dist/paths.js +2 -8
- package/dist/paths.js.map +1 -1
- package/dist/run.d.ts +3 -1
- package/dist/run.js.map +1 -1
- package/dist/sandbox.d.ts +4 -1
- package/dist/sandbox.js +51 -5
- package/dist/sandbox.js.map +1 -1
- package/dist/scenario.d.ts +5 -0
- package/dist/scenario.js +3 -2
- package/dist/scenario.js.map +1 -1
- package/dist/secret.d.ts +2 -0
- package/dist/secret.js +32 -0
- package/dist/secret.js.map +1 -1
- package/dist/shared.d.ts +4 -0
- package/dist/shared.js +7 -0
- package/dist/shared.js.map +1 -1
- package/dist/sites.js +18 -3
- package/dist/sites.js.map +1 -1
- package/dist/steps.d.ts +3 -0
- package/dist/steps.js.map +1 -1
- package/oxlint.json +3 -0
- package/package.json +16 -2
- package/src/apps.ts +17 -1
- package/src/authentication.ts +126 -2
- package/src/call.ts +4 -5
- package/src/client.ts +242 -6
- package/src/cloud-identity.ts +266 -0
- package/src/config.ts +45 -11
- package/src/connection.ts +478 -6
- package/src/db.ts +15 -2
- package/src/describe.ts +52 -1
- package/src/direct.ts +4 -0
- package/src/fake.ts +71 -0
- package/src/index.ts +13 -1
- package/src/kms-secrets.ts +271 -0
- package/src/lint.ts +1 -1
- package/src/load.ts +36 -7
- package/src/op.ts +33 -8
- package/src/outline.ts +35 -2
- package/src/paths.ts +2 -9
- package/src/run.ts +3 -1
- package/src/sandbox.ts +55 -5
- package/src/scenario.ts +24 -6
- package/src/secret.ts +29 -0
- package/src/shared.ts +9 -0
- package/src/sites.ts +19 -3
- package/src/steps.ts +3 -2
package/src/fake.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
import { fail, type Instance, UNREGISTERED, type WakeupApi, wakeupCalls } from "./wakeup-driver.ts";
|
|
12
12
|
import { INSTANCE_TOKEN, instanceUrl } from "./wakeup-service.ts";
|
|
13
13
|
import { wakeupOps } from "./wakeup-type.ts";
|
|
14
|
+
import type { ConnectionRef } from "./connection.ts";
|
|
14
15
|
|
|
15
16
|
/*
|
|
16
17
|
* Fake vendors for tests, at `@sanoma-ai/workflows/fake`. Kept off the main entry so the
|
|
@@ -25,6 +26,76 @@ export interface FakeCall {
|
|
|
25
26
|
at: string;
|
|
26
27
|
idempotencyKey: string;
|
|
27
28
|
attempt: number;
|
|
29
|
+
connection?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A factory per named account: state, faults and idempotent replies never cross connections. */
|
|
33
|
+
export function connectionFakes<F extends Fake<unknown>>(make: () => F) {
|
|
34
|
+
const seen = new WeakSet<object>();
|
|
35
|
+
const fresh = () => {
|
|
36
|
+
const fake = make();
|
|
37
|
+
const parts = [
|
|
38
|
+
fake,
|
|
39
|
+
fake.driver,
|
|
40
|
+
fake.driver.ops,
|
|
41
|
+
...(fake.state && typeof fake.state === "object" ? [fake.state] : []),
|
|
42
|
+
];
|
|
43
|
+
if (parts.some((part) => seen.has(part)))
|
|
44
|
+
throw new Error("A named fake factory must create separate state for each connection");
|
|
45
|
+
for (const part of parts) seen.add(part);
|
|
46
|
+
return fake;
|
|
47
|
+
};
|
|
48
|
+
const first = fresh();
|
|
49
|
+
const { vendor } = first.driver;
|
|
50
|
+
let unused: F | undefined = first;
|
|
51
|
+
const accounts = new Map<string, { fake: F; identity?: string }>();
|
|
52
|
+
const calls: FakeCall[] = [];
|
|
53
|
+
const account = (ref: ConnectionRef): F => {
|
|
54
|
+
if (ref.vendor !== vendor || !ref.$connection.startsWith(`${vendor}/`))
|
|
55
|
+
throw new Error(`fake ${vendor}: select a connection of this connector`);
|
|
56
|
+
let entry = accounts.get(ref.$connection);
|
|
57
|
+
if (!entry) {
|
|
58
|
+
entry = { fake: unused ?? fresh() };
|
|
59
|
+
unused = undefined;
|
|
60
|
+
accounts.set(ref.$connection, entry);
|
|
61
|
+
}
|
|
62
|
+
return entry.fake;
|
|
63
|
+
};
|
|
64
|
+
const ops: Record<string, DriverFn> = {};
|
|
65
|
+
for (const key of Object.keys(first.driver.ops)) {
|
|
66
|
+
ops[key] = async (input, call) => {
|
|
67
|
+
const selected = call.connection;
|
|
68
|
+
if (!selected || selected.vendor !== vendor)
|
|
69
|
+
throw new DriverError(`fake ${vendor}: select a named connection`, { retryable: false });
|
|
70
|
+
const ref = { vendor, $connection: selected.id };
|
|
71
|
+
account(ref);
|
|
72
|
+
let entry = accounts.get(selected.id)!;
|
|
73
|
+
// Rebinding to another vendor account gets fresh state. Rotation within an account does not.
|
|
74
|
+
if (entry.identity !== undefined && entry.identity !== selected.account) {
|
|
75
|
+
entry = { fake: fresh() };
|
|
76
|
+
accounts.set(selected.id, entry);
|
|
77
|
+
}
|
|
78
|
+
entry.identity = selected.account;
|
|
79
|
+
calls.push({
|
|
80
|
+
op: `${vendor}.${key}`,
|
|
81
|
+
input: structuredClone(input),
|
|
82
|
+
at: new Date().toISOString(),
|
|
83
|
+
idempotencyKey: call.idempotencyKey,
|
|
84
|
+
attempt: call.attempt,
|
|
85
|
+
connection: selected.id,
|
|
86
|
+
});
|
|
87
|
+
return entry.fake.driver.ops[key]!(input, call);
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
driver: { vendor, ops } satisfies Driver,
|
|
92
|
+
account,
|
|
93
|
+
calls,
|
|
94
|
+
reset() {
|
|
95
|
+
for (const entry of accounts.values()) entry.fake.reset();
|
|
96
|
+
calls.length = 0;
|
|
97
|
+
},
|
|
98
|
+
};
|
|
28
99
|
}
|
|
29
100
|
|
|
30
101
|
export interface FakeOptions {
|
package/src/index.ts
CHANGED
|
@@ -62,7 +62,7 @@ export {
|
|
|
62
62
|
type SanomaConfig,
|
|
63
63
|
} from "./config.ts";
|
|
64
64
|
// An app's `app.ts` (`defineApp`), and where the launcher finds an app's workflows and data file.
|
|
65
|
-
export { type AppEntry, readApps } from "./apps.ts";
|
|
65
|
+
export { type AppEntry, appSourceOf, readApps } from "./apps.ts";
|
|
66
66
|
export { type AppDefinition, defineApp } from "./definitions.ts";
|
|
67
67
|
export { appFolders, APPS_DIR, filesUnder, kindDirs } from "./paths.ts";
|
|
68
68
|
// `sanoma` itself (the people types' constructors) is at `@sanoma-ai/workflows/resources`, for data files.
|
|
@@ -214,6 +214,7 @@ export {
|
|
|
214
214
|
type ChangesFilter,
|
|
215
215
|
type ChangeView,
|
|
216
216
|
type ClientOptions,
|
|
217
|
+
type ConnectionReview,
|
|
217
218
|
SanomaClient,
|
|
218
219
|
type RunsFilter,
|
|
219
220
|
type RunSummary,
|
|
@@ -285,7 +286,14 @@ export {
|
|
|
285
286
|
checkAuthenticationStore,
|
|
286
287
|
encodeCredentials,
|
|
287
288
|
decodeCredentials,
|
|
289
|
+
authenticationCredentials,
|
|
290
|
+
verifyAuthentication,
|
|
291
|
+
AuthenticationError,
|
|
292
|
+
type AuthenticationProblem,
|
|
288
293
|
type Authentication,
|
|
294
|
+
type AuthenticationIdentity,
|
|
295
|
+
type AuthenticationContext,
|
|
296
|
+
type CallAuthentication,
|
|
289
297
|
} from "./authentication.ts";
|
|
290
298
|
export { checkSecretStore, type SecretStoreCheck } from "./secret-store-check.ts";
|
|
291
299
|
|
|
@@ -301,4 +309,8 @@ export {
|
|
|
301
309
|
type ConnectionDefinition,
|
|
302
310
|
type ConnectionRule,
|
|
303
311
|
type ConnectionSnapshot,
|
|
312
|
+
type ConnectionCard,
|
|
313
|
+
type ConnectionHealth,
|
|
314
|
+
type ConnectionTarget,
|
|
315
|
+
type ConnectionSetup,
|
|
304
316
|
} from "./connection.ts";
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import type { protos } from "@google-cloud/kms";
|
|
3
|
+
import crc32c from "crc-32/crc32c.js";
|
|
4
|
+
import type { Db } from "./db.ts";
|
|
5
|
+
import { SecretStoreError, type SecretAddress, type SecretStore, type SecretValue } from "./secret-store.ts";
|
|
6
|
+
|
|
7
|
+
/** Install with a privileged connection; runtime needs only SELECT/INSERT/UPDATE/DELETE on its company database. */
|
|
8
|
+
export const KMS_SECRETS_SQL = `
|
|
9
|
+
CREATE SCHEMA IF NOT EXISTS sanoma_kms;
|
|
10
|
+
REVOKE ALL ON SCHEMA sanoma_kms FROM PUBLIC;
|
|
11
|
+
CREATE TABLE IF NOT EXISTS sanoma_kms.secrets (
|
|
12
|
+
company text NOT NULL, namespace text NOT NULL, path text NOT NULL,
|
|
13
|
+
version uuid NOT NULL, envelope jsonb NOT NULL,
|
|
14
|
+
updated_by text NOT NULL, updated_at timestamptz NOT NULL DEFAULT now(),
|
|
15
|
+
PRIMARY KEY (company, namespace, path)
|
|
16
|
+
);
|
|
17
|
+
REVOKE ALL ON sanoma_kms.secrets FROM PUBLIC;
|
|
18
|
+
DO $revoke$
|
|
19
|
+
DECLARE api_role text;
|
|
20
|
+
BEGIN
|
|
21
|
+
FOREACH api_role IN ARRAY ARRAY['anon', 'authenticated', 'service_role'] LOOP
|
|
22
|
+
IF EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = api_role) THEN
|
|
23
|
+
EXECUTE format('REVOKE ALL ON SCHEMA sanoma_kms FROM %I', api_role);
|
|
24
|
+
EXECUTE format('REVOKE ALL ON sanoma_kms.secrets FROM %I', api_role);
|
|
25
|
+
END IF;
|
|
26
|
+
END LOOP;
|
|
27
|
+
END;
|
|
28
|
+
$revoke$;
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
/** Promise interface implemented by Google's KeyManagementServiceClient. */
|
|
32
|
+
export interface GcpKmsClient {
|
|
33
|
+
encrypt(
|
|
34
|
+
request: protos.google.cloud.kms.v1.IEncryptRequest,
|
|
35
|
+
): Promise<[protos.google.cloud.kms.v1.IEncryptResponse, ...unknown[]]>;
|
|
36
|
+
decrypt(
|
|
37
|
+
request: protos.google.cloud.kms.v1.IDecryptRequest,
|
|
38
|
+
): Promise<[protos.google.cloud.kms.v1.IDecryptResponse, ...unknown[]]>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface KmsPostgresOptions {
|
|
42
|
+
/** Fixed by trusted deployment bootstrap. IAM must independently restrict the runtime to this company’s CryptoKeys. */
|
|
43
|
+
company: string;
|
|
44
|
+
/** Full GCP CryptoKey resource name for new writes, using its primary version. */
|
|
45
|
+
keyName: string;
|
|
46
|
+
/** Retained CryptoKey names allowed for reads during key replacement/backup recovery. */
|
|
47
|
+
decryptKeyNames?: readonly string[];
|
|
48
|
+
/** Explicit client under a company-scoped IAM identity, never fleet credentials. Caller owns its lifecycle. */
|
|
49
|
+
kms: GcpKmsClient;
|
|
50
|
+
/** Company-scoped database access. Caller owns connection lifecycle; never shared admin authority. */
|
|
51
|
+
database: () => Promise<Db>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface Envelope {
|
|
55
|
+
format: "sanoma-gcp-kms-v1";
|
|
56
|
+
key: string;
|
|
57
|
+
keyVersion: string;
|
|
58
|
+
wrapped: string;
|
|
59
|
+
nonce: string;
|
|
60
|
+
ciphertext: string;
|
|
61
|
+
tag: string;
|
|
62
|
+
}
|
|
63
|
+
interface Row {
|
|
64
|
+
version: string;
|
|
65
|
+
envelope: Envelope;
|
|
66
|
+
updated_by: string;
|
|
67
|
+
updated_at: Date;
|
|
68
|
+
}
|
|
69
|
+
const FORMAT = "sanoma-gcp-kms-v1";
|
|
70
|
+
const MAX_BYTES = 65_536;
|
|
71
|
+
const keyName =
|
|
72
|
+
/^projects\/[a-zA-Z0-9-]+\/locations\/[a-z0-9-]+\/keyRings\/[a-zA-Z0-9_-]+\/cryptoKeys\/[a-zA-Z0-9_-]+$/;
|
|
73
|
+
const nonempty = (v: string) =>
|
|
74
|
+
typeof v === "string" && v.length > 0 && Buffer.byteLength(v, "utf8") <= 1024 && !v.includes("\0");
|
|
75
|
+
const failed = (error: unknown): never => {
|
|
76
|
+
if (error instanceof SecretStoreError) throw error;
|
|
77
|
+
const code = (error as { code?: string | number } | null)?.code;
|
|
78
|
+
throw new SecretStoreError(
|
|
79
|
+
code === 7 || code === 16 || code === "42501"
|
|
80
|
+
? "denied"
|
|
81
|
+
: code === 3
|
|
82
|
+
? "invalid_value"
|
|
83
|
+
: code === 5 || code === 9 || code === "42P01"
|
|
84
|
+
? "invalid_configuration"
|
|
85
|
+
: "unavailable",
|
|
86
|
+
);
|
|
87
|
+
};
|
|
88
|
+
const safe = async <T>(run: () => Promise<T>): Promise<T> => {
|
|
89
|
+
try {
|
|
90
|
+
return await run();
|
|
91
|
+
} catch (e) {
|
|
92
|
+
return failed(e);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
const bytes = (v: string, length?: number, max = MAX_BYTES): Buffer => {
|
|
96
|
+
if (
|
|
97
|
+
typeof v !== "string" ||
|
|
98
|
+
v.length > Math.ceil((length ?? max) / 3) * 4 ||
|
|
99
|
+
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(v)
|
|
100
|
+
)
|
|
101
|
+
throw new SecretStoreError("invalid_value");
|
|
102
|
+
const b = Buffer.from(v, "base64");
|
|
103
|
+
if (length !== undefined && b.length !== length) throw new SecretStoreError("invalid_value");
|
|
104
|
+
return b;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const versionOf = (name: unknown, key: string): name is string =>
|
|
108
|
+
typeof name === "string" &&
|
|
109
|
+
name.startsWith(`${key}/cryptoKeyVersions/`) &&
|
|
110
|
+
/^[1-9][0-9]{0,19}$/.test(name.slice(`${key}/cryptoKeyVersions/`.length));
|
|
111
|
+
const checksum = (value: Uint8Array) => crc32c.buf(value) >>> 0;
|
|
112
|
+
|
|
113
|
+
/** Versioned envelope encryption; no plaintext/data-key cache, no SANOMA_SECRETS_KEY and no Secrets Manager. */
|
|
114
|
+
export function kmsPostgresStore(options: KmsPostgresOptions): SecretStore {
|
|
115
|
+
const { company, keyName: writeKey, kms, database } = options;
|
|
116
|
+
const keys = new Set([writeKey, ...(options.decryptKeyNames ?? [])]);
|
|
117
|
+
if (!nonempty(company) || [...keys].some((k) => !nonempty(k) || !keyName.test(k)))
|
|
118
|
+
throw new SecretStoreError("invalid_configuration");
|
|
119
|
+
const context = (address: SecretAddress, version: string) => {
|
|
120
|
+
if (!nonempty(address.namespace) || !nonempty(address.path)) throw new SecretStoreError("invalid_configuration");
|
|
121
|
+
return { company, namespace: address.namespace, path: address.path, version, format: FORMAT };
|
|
122
|
+
};
|
|
123
|
+
const seal = async (address: SecretAddress, value: string, version: string): Promise<Envelope> => {
|
|
124
|
+
const bound = context(address, version);
|
|
125
|
+
const aad = Buffer.from(JSON.stringify(bound));
|
|
126
|
+
const dataKey = randomBytes(32);
|
|
127
|
+
try {
|
|
128
|
+
const [encrypted] = await kms.encrypt({
|
|
129
|
+
name: writeKey,
|
|
130
|
+
plaintext: dataKey,
|
|
131
|
+
plaintextCrc32c: { value: checksum(dataKey) },
|
|
132
|
+
additionalAuthenticatedData: aad,
|
|
133
|
+
additionalAuthenticatedDataCrc32c: { value: checksum(aad) },
|
|
134
|
+
});
|
|
135
|
+
const wrapped =
|
|
136
|
+
typeof encrypted.ciphertext === "string" ? bytes(encrypted.ciphertext, undefined, 6144) : encrypted.ciphertext;
|
|
137
|
+
if (
|
|
138
|
+
!versionOf(encrypted.name, writeKey) ||
|
|
139
|
+
!wrapped?.length ||
|
|
140
|
+
wrapped.length > 6144 ||
|
|
141
|
+
!encrypted.verifiedPlaintextCrc32c ||
|
|
142
|
+
!encrypted.verifiedAdditionalAuthenticatedDataCrc32c ||
|
|
143
|
+
Number(encrypted.ciphertextCrc32c?.value ?? NaN) !== checksum(wrapped)
|
|
144
|
+
)
|
|
145
|
+
throw new SecretStoreError("invalid_value");
|
|
146
|
+
const nonce = randomBytes(12);
|
|
147
|
+
const cipher = createCipheriv("aes-256-gcm", dataKey, nonce).setAAD(
|
|
148
|
+
Buffer.from(JSON.stringify({ ...bound, keyVersion: encrypted.name })),
|
|
149
|
+
);
|
|
150
|
+
const ciphertext = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
|
|
151
|
+
return {
|
|
152
|
+
format: FORMAT,
|
|
153
|
+
key: writeKey,
|
|
154
|
+
keyVersion: encrypted.name,
|
|
155
|
+
wrapped: Buffer.from(wrapped).toString("base64"),
|
|
156
|
+
nonce: nonce.toString("base64"),
|
|
157
|
+
ciphertext: ciphertext.toString("base64"),
|
|
158
|
+
tag: cipher.getAuthTag().toString("base64"),
|
|
159
|
+
};
|
|
160
|
+
} finally {
|
|
161
|
+
dataKey.fill(0);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
const open = async (address: SecretAddress, row: Row): Promise<SecretValue> => {
|
|
165
|
+
const e = row.envelope;
|
|
166
|
+
if (!e || e.format !== FORMAT || !keys.has(e.key) || !versionOf(e.keyVersion, e.key))
|
|
167
|
+
throw new SecretStoreError("invalid_value");
|
|
168
|
+
const nonce = bytes(e.nonce, 12),
|
|
169
|
+
tag = bytes(e.tag, 16),
|
|
170
|
+
ciphertext = bytes(e.ciphertext),
|
|
171
|
+
wrapped = bytes(e.wrapped, undefined, 6144);
|
|
172
|
+
if (ciphertext.length > MAX_BYTES || !wrapped.length || wrapped.length > 6144)
|
|
173
|
+
throw new SecretStoreError("invalid_value");
|
|
174
|
+
const bound = context(address, row.version);
|
|
175
|
+
const aad = Buffer.from(JSON.stringify(bound));
|
|
176
|
+
const [decrypted] = await kms.decrypt({
|
|
177
|
+
name: e.key,
|
|
178
|
+
ciphertext: wrapped,
|
|
179
|
+
ciphertextCrc32c: { value: checksum(wrapped) },
|
|
180
|
+
additionalAuthenticatedData: aad,
|
|
181
|
+
additionalAuthenticatedDataCrc32c: { value: checksum(aad) },
|
|
182
|
+
});
|
|
183
|
+
const dataKey = typeof decrypted.plaintext === "string" ? bytes(decrypted.plaintext, 32) : decrypted.plaintext;
|
|
184
|
+
try {
|
|
185
|
+
if (dataKey?.length !== 32 || Number(decrypted.plaintextCrc32c?.value ?? NaN) !== checksum(dataKey))
|
|
186
|
+
throw new SecretStoreError("invalid_value");
|
|
187
|
+
const decipher = createDecipheriv("aes-256-gcm", dataKey, nonce)
|
|
188
|
+
.setAAD(Buffer.from(JSON.stringify({ ...bound, keyVersion: e.keyVersion })))
|
|
189
|
+
.setAuthTag(tag);
|
|
190
|
+
let plaintext: Buffer;
|
|
191
|
+
try {
|
|
192
|
+
plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
193
|
+
} catch {
|
|
194
|
+
throw new SecretStoreError("invalid_value");
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
return {
|
|
198
|
+
value: plaintext.toString("utf8"),
|
|
199
|
+
version: row.version,
|
|
200
|
+
updatedBy: row.updated_by,
|
|
201
|
+
updatedAt: row.updated_at.toISOString(),
|
|
202
|
+
};
|
|
203
|
+
} finally {
|
|
204
|
+
plaintext.fill(0);
|
|
205
|
+
}
|
|
206
|
+
} finally {
|
|
207
|
+
dataKey?.fill(0);
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
return {
|
|
211
|
+
id: "kms-postgres",
|
|
212
|
+
capabilities: Object.freeze({
|
|
213
|
+
writable: true,
|
|
214
|
+
maxBytes: MAX_BYTES,
|
|
215
|
+
conditionalWrites: "atomic",
|
|
216
|
+
deletion: "destroy",
|
|
217
|
+
changeDetection: "read",
|
|
218
|
+
}),
|
|
219
|
+
validate: (namespace) =>
|
|
220
|
+
safe(async () => {
|
|
221
|
+
const address = { namespace, path: "__validation__" },
|
|
222
|
+
version = randomUUID();
|
|
223
|
+
const envelope = await seal(address, "validation", version);
|
|
224
|
+
await open(address, { envelope, version, updated_by: "validation", updated_at: new Date() });
|
|
225
|
+
await (await database()).query("SELECT version FROM sanoma_kms.secrets WHERE company=$1 LIMIT 0", [company]);
|
|
226
|
+
// Own-key usability only. Cross-company authorization requires live IAM proof with another existing key.
|
|
227
|
+
}),
|
|
228
|
+
read: (address) =>
|
|
229
|
+
safe(async () => {
|
|
230
|
+
context(address, "read");
|
|
231
|
+
const { rows } = await (
|
|
232
|
+
await database()
|
|
233
|
+
).query<Row>(
|
|
234
|
+
"SELECT version,envelope,updated_by,updated_at FROM sanoma_kms.secrets WHERE company=$1 AND namespace=$2 AND path=$3",
|
|
235
|
+
[company, address.namespace, address.path],
|
|
236
|
+
);
|
|
237
|
+
return rows[0] && open(address, rows[0]);
|
|
238
|
+
}),
|
|
239
|
+
write: (address, value, { expectedVersion, by }) =>
|
|
240
|
+
safe(async () => {
|
|
241
|
+
if (Buffer.byteLength(value, "utf8") > MAX_BYTES) throw new SecretStoreError("too_large");
|
|
242
|
+
if (!nonempty(by)) throw new SecretStoreError("invalid_value");
|
|
243
|
+
const version = randomUUID(),
|
|
244
|
+
envelope = await seal(address, value, version);
|
|
245
|
+
const args = [company, address.namespace, address.path, version, JSON.stringify(envelope), by];
|
|
246
|
+
const sql =
|
|
247
|
+
expectedVersion === null
|
|
248
|
+
? "INSERT INTO sanoma_kms.secrets(company,namespace,path,version,envelope,updated_by) VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT(company,namespace,path) DO NOTHING RETURNING version,envelope,updated_by,updated_at"
|
|
249
|
+
: "UPDATE sanoma_kms.secrets SET version=$4,envelope=$5,updated_by=$6,updated_at=now() WHERE company=$1 AND namespace=$2 AND path=$3 AND version::text=$7 RETURNING version,envelope,updated_by,updated_at";
|
|
250
|
+
const { rows } = await (
|
|
251
|
+
await database()
|
|
252
|
+
).query<Row>(sql, expectedVersion === null ? args : [...args, expectedVersion]);
|
|
253
|
+
if (!rows[0]) throw new SecretStoreError("conflict");
|
|
254
|
+
// Return the caller's value, never make a second decrypt after a successful commit.
|
|
255
|
+
return { value, version, updatedBy: by, updatedAt: rows[0].updated_at.toISOString() };
|
|
256
|
+
}),
|
|
257
|
+
delete: (address, { expectedVersion }) =>
|
|
258
|
+
safe(async () => {
|
|
259
|
+
context(address, expectedVersion);
|
|
260
|
+
const { rowCount } = await (
|
|
261
|
+
await database()
|
|
262
|
+
).query("DELETE FROM sanoma_kms.secrets WHERE company=$1 AND namespace=$2 AND path=$3 AND version::text=$4", [
|
|
263
|
+
company,
|
|
264
|
+
address.namespace,
|
|
265
|
+
address.path,
|
|
266
|
+
expectedVersion,
|
|
267
|
+
]);
|
|
268
|
+
if (!rowCount) throw new SecretStoreError("conflict");
|
|
269
|
+
}),
|
|
270
|
+
};
|
|
271
|
+
}
|
package/src/lint.ts
CHANGED
|
@@ -28,7 +28,7 @@ const ALLOWED_PACKAGE = new RegExp(`^(${[...AUTHOR_PACKAGES, "@sanoma-ai/connect
|
|
|
28
28
|
const REFUSED_PACKAGES: [RegExp, string][] = [
|
|
29
29
|
[/^@sanoma-ai\/testing(\/|$)/, `fakes and test helpers belong in tests; ${THROUGH_CTX}`],
|
|
30
30
|
[/^@sanoma-ai\/app(\/|$)/, "the app starts runs and decides approvals, so a workflow could approve itself"],
|
|
31
|
-
[/^@sanoma-ai\/connector-[^/]+\/(fake|driver|remote)(\/|$)/, `${THROUGH_CTX}; import the connector itself`],
|
|
31
|
+
[/^@sanoma-ai\/connector-[^/]+\/(fake|driver|remote|vault)(\/|$)/, `${THROUGH_CTX}; import the connector itself`],
|
|
32
32
|
[/^sanoma\//, "`sanoma` has no entry points for a workflow: import its names from `sanoma` itself"],
|
|
33
33
|
];
|
|
34
34
|
|
package/src/load.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync, realpathSync, statSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
2
|
import { createRequire, registerHooks } from "node:module";
|
|
3
3
|
import { dirname, isAbsolute, join, relative, sep } from "node:path";
|
|
4
4
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
@@ -254,20 +254,47 @@ function placeOf(gen: Generation, error: unknown): { file: string; line?: number
|
|
|
254
254
|
return undefined;
|
|
255
255
|
}
|
|
256
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Each company file under `root` a load has read, and each one's folder, with its stamp
|
|
259
|
+
* (`stampOf`) as it was before the file was read: the latest generation's for a file several
|
|
260
|
+
* read. Taken after a load, it still says what the load read, so a file changed while it read is
|
|
261
|
+
* found changed after.
|
|
262
|
+
*/
|
|
263
|
+
export function stampsOf(root: string): Map<string, string> {
|
|
264
|
+
const stamps = new Map<string, string>();
|
|
265
|
+
if (!existsSync(root)) return stamps;
|
|
266
|
+
const real = realpathSync(root);
|
|
267
|
+
// In the order made: a later generation's stamp is the newer.
|
|
268
|
+
for (const gen of registry.generations.values()) {
|
|
269
|
+
if (gen.root === real) for (const [path, stamp] of gen.stamps) stamps.set(path, stamp);
|
|
270
|
+
}
|
|
271
|
+
return stamps;
|
|
272
|
+
}
|
|
273
|
+
|
|
257
274
|
const byOrder = <T extends string | number>(a: T, b: T) => (a < b ? -1 : a > b ? 1 : 0);
|
|
258
275
|
|
|
259
|
-
/** A file's size and modification time, or that it is gone. */
|
|
260
|
-
function stampOf(path: string): string {
|
|
276
|
+
/** A file's size and modification time, or that it is gone: what a generation keeps of each file it read. */
|
|
277
|
+
export function stampOf(path: string): string {
|
|
261
278
|
const stat = statSync(path, { bigint: true, throwIfNoEntry: false });
|
|
262
279
|
return stat ? `${stat.size} ${stat.mtimeNs}` : "gone";
|
|
263
280
|
}
|
|
264
281
|
|
|
282
|
+
/** True while each path's stamp (`stampOf`) is still the one kept. */
|
|
283
|
+
export function fresh(stamps: ReadonlyMap<string, string>): boolean {
|
|
284
|
+
for (const [path, stamp] of stamps) if (stampOf(path) !== stamp) return false;
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
|
|
265
288
|
/** Records a file of the generation, and its directory, as they are now: before it is read, so an edit after is seen. */
|
|
266
289
|
function touch(gen: Generation, file: string) {
|
|
267
290
|
for (const path of [file, dirname(file)]) if (!gen.stamps.has(path)) gen.stamps.set(path, stampOf(path));
|
|
268
291
|
}
|
|
269
292
|
|
|
270
|
-
/**
|
|
293
|
+
/**
|
|
294
|
+
* The generation of `root` and these constructors: the current one while nothing it stamped
|
|
295
|
+
* changed, else a new one. A generation of `root` whose files changed goes, whatever its
|
|
296
|
+
* constructors, so `stampsOf` never keeps the stamps of one no load will make again.
|
|
297
|
+
*/
|
|
271
298
|
function generationOf(
|
|
272
299
|
root: string,
|
|
273
300
|
connectors: readonly Connector<any, any>[],
|
|
@@ -278,11 +305,13 @@ function generationOf(
|
|
|
278
305
|
return info?.package ? [`${info.package}/resources`, id] : [];
|
|
279
306
|
});
|
|
280
307
|
const typesKey = [...packages, ...[...types.values()].map(idOf)].join(" ");
|
|
308
|
+
let current: Generation | undefined;
|
|
281
309
|
for (const gen of registry.generations.values()) {
|
|
282
|
-
if (gen.root !== root
|
|
283
|
-
if (
|
|
284
|
-
|
|
310
|
+
if (gen.root !== root) continue;
|
|
311
|
+
if (!fresh(gen.stamps)) registry.generations.delete(gen.id);
|
|
312
|
+
else if (gen.typesKey === typesKey) current = gen;
|
|
285
313
|
}
|
|
314
|
+
if (current !== undefined) return current;
|
|
286
315
|
const gen: Generation = {
|
|
287
316
|
id: String(++registry.seq),
|
|
288
317
|
root,
|
package/src/op.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Principal } from "./define.ts";
|
|
2
|
-
import type { Authentication } from "./authentication.ts";
|
|
2
|
+
import type { Authentication, CallAuthentication } from "./authentication.ts";
|
|
3
3
|
import type { ConnectionSnapshot } from "./connection.ts";
|
|
4
4
|
import type { ProviderV4 } from "@ai-sdk/provider";
|
|
5
5
|
import type { z } from "zod";
|
|
@@ -41,6 +41,8 @@ export interface OpSpec<I extends z.ZodType = z.ZodType, O extends z.ZodType = z
|
|
|
41
41
|
* (a resource type's `handle`): the ledger records each as `"<name>"`, never its value.
|
|
42
42
|
*/
|
|
43
43
|
opaque?: readonly string[];
|
|
44
|
+
/** Logical destinations this operation may create with Secrets.ensure, derived from its authorized input. */
|
|
45
|
+
createsSecrets?: (input: any) => readonly string[];
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
/** One vendor operation, such as `ghost.post.publish`. Declares the contract only; drivers implement it. */
|
|
@@ -59,6 +61,7 @@ export interface Op<V extends string = string, R extends string = string, N exte
|
|
|
59
61
|
readonly phrases?: { readonly given?: string; readonly expect?: string };
|
|
60
62
|
/** From the spec: fields the ledger records by name only. */
|
|
61
63
|
readonly opaque?: readonly string[];
|
|
64
|
+
readonly createsSecrets?: (input: any) => readonly string[];
|
|
62
65
|
readonly input: z.ZodType<any, I>;
|
|
63
66
|
readonly output: z.ZodType<O>;
|
|
64
67
|
}
|
|
@@ -111,7 +114,7 @@ export type Connector<V extends string, S extends Specs> = {
|
|
|
111
114
|
* and `resolveConfig` reads only a record of its own shape (`checkConnector`): change it with
|
|
112
115
|
* the record, so a build refuses a connector another build made in another shape.
|
|
113
116
|
*/
|
|
114
|
-
export const CONNECTOR_FORMAT =
|
|
117
|
+
export const CONNECTOR_FORMAT = 3;
|
|
115
118
|
|
|
116
119
|
/**
|
|
117
120
|
* A connector's vendor: its id, who it is (`defineConnector`'s third argument, for a UI), its
|
|
@@ -212,6 +215,7 @@ export function defineConnector<const V extends string, const S extends Specs>(
|
|
|
212
215
|
target: spec.target,
|
|
213
216
|
phrases: spec.phrases,
|
|
214
217
|
...(spec.opaque?.length ? { opaque: spec.opaque } : {}),
|
|
218
|
+
...(spec.createsSecrets && { createsSecrets: spec.createsSecrets }),
|
|
215
219
|
input: spec.input,
|
|
216
220
|
output: spec.output,
|
|
217
221
|
} satisfies Op);
|
|
@@ -243,6 +247,8 @@ export interface CallContext {
|
|
|
243
247
|
principal?: Principal;
|
|
244
248
|
/** Authorized account metadata. Credential material stays inside the driver boundary. */
|
|
245
249
|
connection?: ConnectionSnapshot;
|
|
250
|
+
/** Resolved after policy, for this call alone. Never return it from a driver. */
|
|
251
|
+
authentication?: CallAuthentication;
|
|
246
252
|
idempotencyKey: string;
|
|
247
253
|
runId: string;
|
|
248
254
|
opId: string;
|
|
@@ -302,13 +308,15 @@ export function retryableStatus(status: number): boolean {
|
|
|
302
308
|
export type DriverEnv = z.ZodObject<Record<string, z.ZodType<string | undefined, string | undefined>>>;
|
|
303
309
|
|
|
304
310
|
/**
|
|
305
|
-
* Implements a vendor's operations, keyed `"resource.name"`.
|
|
306
|
-
*
|
|
311
|
+
* Implements a vendor's operations, keyed `"resource.name"`. Named authentication is supplied
|
|
312
|
+
* in the call context. `env` describes a driver that has not added named-account support.
|
|
307
313
|
*/
|
|
308
314
|
export interface Driver {
|
|
309
315
|
readonly vendor: string;
|
|
310
316
|
readonly ops: Readonly<Record<string, DriverFn>>;
|
|
311
317
|
readonly env?: DriverEnv;
|
|
318
|
+
/** Methods this driver consumes from CallContext.authentication. Others fail closed. */
|
|
319
|
+
readonly authentication?: readonly string[];
|
|
312
320
|
}
|
|
313
321
|
|
|
314
322
|
/**
|
|
@@ -322,7 +330,7 @@ export interface Driver {
|
|
|
322
330
|
export interface ConnectorSetup {
|
|
323
331
|
/** Auth methods available to named connections; login identity is separate. */
|
|
324
332
|
authentication?: Readonly<Record<string, Authentication>>;
|
|
325
|
-
/** Makes the live driver
|
|
333
|
+
/** Makes the live driver once per resolved config; credentials belong to each invocation. */
|
|
326
334
|
driver?: () => Driver;
|
|
327
335
|
/** Makes a fake of the vendor (`defineFake`): once per sandbox run, its own. */
|
|
328
336
|
fake?: () => Fake<any, any>;
|
|
@@ -466,8 +474,8 @@ export type OpIdOf<V extends string, S extends Specs> = {
|
|
|
466
474
|
*/
|
|
467
475
|
export function defineDriver<V extends string, S extends Specs>(
|
|
468
476
|
connector: Connector<V, S>,
|
|
469
|
-
impl: DriverImpl<S
|
|
470
|
-
options: { env?: DriverEnv } = {},
|
|
477
|
+
impl: DriverImpl<S> | ((call: CallContext) => DriverImpl<S>),
|
|
478
|
+
options: { env?: DriverEnv; authentication?: readonly string[] } = {},
|
|
471
479
|
): Driver {
|
|
472
480
|
const declared = new Map<string, Op>();
|
|
473
481
|
for (const resource of Object.values(connector as Record<string, Record<string, unknown>>)) {
|
|
@@ -475,6 +483,18 @@ export function defineDriver<V extends string, S extends Specs>(
|
|
|
475
483
|
}
|
|
476
484
|
const vendor = connector[VENDOR].id;
|
|
477
485
|
const ops: Record<string, DriverFn> = {};
|
|
486
|
+
if (typeof impl === "function") {
|
|
487
|
+
// Build clients only once the runtime has authorized and authenticated this invocation.
|
|
488
|
+
for (const key of declared.keys()) {
|
|
489
|
+
ops[key] = async (input, call) => defineDriver(connector, impl(call), options).ops[key]!(input, call);
|
|
490
|
+
}
|
|
491
|
+
return Object.freeze({
|
|
492
|
+
vendor,
|
|
493
|
+
ops: Object.freeze(ops),
|
|
494
|
+
...(options.env && { env: options.env }),
|
|
495
|
+
...(options.authentication && { authentication: Object.freeze([...options.authentication]) }),
|
|
496
|
+
});
|
|
497
|
+
}
|
|
478
498
|
const extra: string[] = [];
|
|
479
499
|
for (const [resource, fns] of Object.entries(impl as Record<string, Record<string, unknown>>)) {
|
|
480
500
|
for (const [name, fn] of Object.entries(fns ?? {})) {
|
|
@@ -489,5 +509,10 @@ export function defineDriver<V extends string, S extends Specs>(
|
|
|
489
509
|
...(extra.length ? [`it implements ${extra.join(", ")}, which the connector does not declare`] : []),
|
|
490
510
|
];
|
|
491
511
|
if (problems.length) throw new Error(`defineDriver("${vendor}"): ${problems.join("; ")}`);
|
|
492
|
-
return Object.freeze({
|
|
512
|
+
return Object.freeze({
|
|
513
|
+
vendor,
|
|
514
|
+
ops: Object.freeze(ops),
|
|
515
|
+
...(options.env && { env: options.env }),
|
|
516
|
+
...(options.authentication && { authentication: Object.freeze([...options.authentication]) }),
|
|
517
|
+
});
|
|
493
518
|
}
|
package/src/outline.ts
CHANGED
|
@@ -44,7 +44,7 @@ export type OutlineNode = { path: string; span: Span } &
|
|
|
44
44
|
*/
|
|
45
45
|
/** `name`: the one the call gives (`ctx.x.y.z(input, { name: "…" })`), else the id when no segment is computed. */
|
|
46
46
|
(
|
|
47
|
-
| { kind: "op"; id: string; ops?: string[]; name?: string }
|
|
47
|
+
| { kind: "op"; id: string; ops?: string[]; name?: string; connection?: { id: string } | { dynamic: true } }
|
|
48
48
|
/**
|
|
49
49
|
* `ctx.approval(...)`, with its title, and the title as its `name`, when the first argument is
|
|
50
50
|
* a string literal or a top-level `const` holding one.
|
|
@@ -417,6 +417,31 @@ export function outlineBody(
|
|
|
417
417
|
return undefined;
|
|
418
418
|
};
|
|
419
419
|
|
|
420
|
+
// Dependency information only. Execution still resolves and authorizes the actual reference.
|
|
421
|
+
// An imported/computed reference stays unknown rather than being guessed from its variable name.
|
|
422
|
+
const connectionIn = (options: Node | undefined): { id: string } | { dynamic: true } | undefined => {
|
|
423
|
+
if (!options) return undefined;
|
|
424
|
+
const object = options && unwrap(options);
|
|
425
|
+
if (object?.type !== "ObjectExpression") return { dynamic: true };
|
|
426
|
+
if (object.properties.some((p: Node) => p.type !== "Property" || p.computed)) return { dynamic: true };
|
|
427
|
+
const property =
|
|
428
|
+
object?.type === "ObjectExpression" &&
|
|
429
|
+
object.properties.findLast(
|
|
430
|
+
(p: Node) => p.type === "Property" && !p.computed && (p.key.name ?? p.key.value) === "connection",
|
|
431
|
+
);
|
|
432
|
+
if (!property) return undefined;
|
|
433
|
+
const ref = unwrap(property.value);
|
|
434
|
+
if (ref?.type !== "ObjectExpression") return { dynamic: true };
|
|
435
|
+
if (ref.properties.some((p: Node) => p.type !== "Property" || p.computed)) return { dynamic: true };
|
|
436
|
+
const key =
|
|
437
|
+
ref?.type === "ObjectExpression" &&
|
|
438
|
+
ref.properties.findLast(
|
|
439
|
+
(p: Node) => p.type === "Property" && !p.computed && (p.key.name ?? p.key.value) === "$connection",
|
|
440
|
+
);
|
|
441
|
+
const id = key && literalOf(key.value, consts);
|
|
442
|
+
return typeof id === "string" ? { id } : { dynamic: true };
|
|
443
|
+
};
|
|
444
|
+
|
|
420
445
|
// The arguments are evaluated before the call is made, so their calls come first.
|
|
421
446
|
const call = (node: Node, at: Where): OutlineNode[] => {
|
|
422
447
|
const args: Node[] = node.arguments;
|
|
@@ -442,9 +467,17 @@ export function outlineBody(
|
|
|
442
467
|
if (isOpPath(members)) {
|
|
443
468
|
// A computed id's own name is the operation it calls, known only in a run.
|
|
444
469
|
const name = nameIn(args[1], true) ?? (id.includes("*") ? undefined : id);
|
|
470
|
+
const connection = connectionIn(args[1]);
|
|
445
471
|
return [
|
|
446
472
|
...walk(args, at),
|
|
447
|
-
{
|
|
473
|
+
{
|
|
474
|
+
kind: "op",
|
|
475
|
+
path,
|
|
476
|
+
id,
|
|
477
|
+
span: span(node),
|
|
478
|
+
...(name === undefined ? {} : { name }),
|
|
479
|
+
...(connection && { connection }),
|
|
480
|
+
},
|
|
448
481
|
];
|
|
449
482
|
}
|
|
450
483
|
return READS.has(id) ? walk(args, at) : [...walk(args, at), ...problem(node, CTX_CALL(id))];
|