@sanoma-ai/workflows 0.10.0 → 0.10.1
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/README.md +33 -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 +30 -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/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 +334 -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/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 +1 -0
- package/package.json +2 -1
- package/src/apps.ts +17 -1
- package/src/authentication.ts +119 -2
- package/src/call.ts +4 -5
- package/src/client.ts +240 -6
- package/src/config.ts +45 -11
- package/src/connection.ts +472 -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 +12 -1
- 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/connection.ts
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
import type { Principal } from "./define.ts";
|
|
2
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
checkAuthenticationStore,
|
|
6
|
+
decodeCredentials,
|
|
7
|
+
defineAuthentication,
|
|
8
|
+
verifyAuthentication,
|
|
9
|
+
AuthenticationError,
|
|
10
|
+
authenticationProblem,
|
|
11
|
+
type AuthenticationProblem,
|
|
12
|
+
type Authentication,
|
|
13
|
+
type CallAuthentication,
|
|
14
|
+
} from "./authentication.ts";
|
|
5
15
|
import { type Connector, setupOf, VENDOR, defineConnector, defineDriver, type CallContext } from "./op.ts";
|
|
6
16
|
import { isSecret, type Secret } from "./secret.ts";
|
|
7
17
|
import { SanomaError } from "./errors.ts";
|
|
8
|
-
import type
|
|
18
|
+
import { SecretStoreError, type SecretAccess, type SecretLocation } from "./secret-store.ts";
|
|
9
19
|
import type { Db } from "./db.ts";
|
|
10
20
|
|
|
11
21
|
/** Serializable account reference; vendor literal prevents cross-connector selection in typed calls. */
|
|
@@ -40,6 +50,51 @@ export interface ConnectionSnapshot {
|
|
|
40
50
|
/** A logical reference only, never the value or a provider credential. */
|
|
41
51
|
credentials?: Secret;
|
|
42
52
|
}
|
|
53
|
+
export type ConnectionTarget = { connection: ConnectionRef } | { rule: string };
|
|
54
|
+
export interface ConnectionCard {
|
|
55
|
+
id: string;
|
|
56
|
+
vendor: string;
|
|
57
|
+
name: string;
|
|
58
|
+
method: string;
|
|
59
|
+
account?: string;
|
|
60
|
+
scopes: readonly string[];
|
|
61
|
+
requiredScopes: readonly string[];
|
|
62
|
+
expectedAccount?: string;
|
|
63
|
+
revision?: string;
|
|
64
|
+
owner?: string;
|
|
65
|
+
status: "missing" | "connected" | "disconnected" | "pending" | "uncertain" | "configuration_changed";
|
|
66
|
+
location?: SecretLocation;
|
|
67
|
+
boundLocation?: SecretLocation;
|
|
68
|
+
updatedBy?: string;
|
|
69
|
+
updatedAt?: string;
|
|
70
|
+
health?: ConnectionHealth;
|
|
71
|
+
}
|
|
72
|
+
export interface ConnectionHealth {
|
|
73
|
+
state:
|
|
74
|
+
| AuthenticationProblem
|
|
75
|
+
| "connected"
|
|
76
|
+
| "disconnected"
|
|
77
|
+
| "pending"
|
|
78
|
+
| "configuration_changed"
|
|
79
|
+
| "store_denied"
|
|
80
|
+
| "invalid";
|
|
81
|
+
checkedAt: string;
|
|
82
|
+
externallyRenewed: boolean;
|
|
83
|
+
}
|
|
84
|
+
export interface ConnectionSetup {
|
|
85
|
+
target: ConnectionTarget;
|
|
86
|
+
id: string;
|
|
87
|
+
vendor: string;
|
|
88
|
+
method: string;
|
|
89
|
+
definition: string;
|
|
90
|
+
credential?: string;
|
|
91
|
+
location?: SecretLocation;
|
|
92
|
+
expectedRevision: string | null;
|
|
93
|
+
requiredScopes: readonly string[];
|
|
94
|
+
expectedAccount?: string;
|
|
95
|
+
previousAccount?: string;
|
|
96
|
+
previousLocation?: SecretLocation;
|
|
97
|
+
}
|
|
43
98
|
const idPart = /^[a-z][a-z0-9-]{0,63}$/;
|
|
44
99
|
function refuse(message: string): never {
|
|
45
100
|
throw new SanomaError("invalid_input", message);
|
|
@@ -59,6 +114,28 @@ const snapshotSchema = z.object({
|
|
|
59
114
|
definition: z.string(),
|
|
60
115
|
credentials: z.object({ $secret: z.string() }).optional(),
|
|
61
116
|
});
|
|
117
|
+
export const connectionTargetSchema = z.union([
|
|
118
|
+
z.object({ connection: connectionRefSchema }),
|
|
119
|
+
z.object({ rule: z.string().min(1) }),
|
|
120
|
+
]);
|
|
121
|
+
const setupSchema = z.object({
|
|
122
|
+
target: connectionTargetSchema,
|
|
123
|
+
id: z.string(),
|
|
124
|
+
vendor: z.string(),
|
|
125
|
+
method: z.string(),
|
|
126
|
+
definition: z.string(),
|
|
127
|
+
credential: z.string().optional(),
|
|
128
|
+
location: z
|
|
129
|
+
.object({ store: z.string(), namespace: z.string(), path: z.string(), management: z.enum(["sanoma", "external"]) })
|
|
130
|
+
.optional(),
|
|
131
|
+
expectedRevision: z.string().nullable(),
|
|
132
|
+
requiredScopes: z.array(z.string()),
|
|
133
|
+
expectedAccount: z.string().optional(),
|
|
134
|
+
previousAccount: z.string().optional(),
|
|
135
|
+
previousLocation: z
|
|
136
|
+
.object({ store: z.string(), namespace: z.string(), path: z.string(), management: z.enum(["sanoma", "external"]) })
|
|
137
|
+
.optional(),
|
|
138
|
+
});
|
|
62
139
|
|
|
63
140
|
export function defineConnection<C extends Connector<any, any>>(
|
|
64
141
|
connector: C,
|
|
@@ -189,7 +266,13 @@ type ConnectionRow = {
|
|
|
189
266
|
revision: string;
|
|
190
267
|
definition: string;
|
|
191
268
|
credential: string | null;
|
|
192
|
-
|
|
269
|
+
location?: SecretLocation | null;
|
|
270
|
+
status: "connected" | "disconnected" | "pending" | "uncertain";
|
|
271
|
+
updated_by?: string;
|
|
272
|
+
updated_at?: Date;
|
|
273
|
+
health?: ConnectionHealth["state"];
|
|
274
|
+
checked_at?: Date;
|
|
275
|
+
externally_renewed?: boolean;
|
|
193
276
|
};
|
|
194
277
|
const snapshot = (r: ConnectionRow): ConnectionSnapshot => ({
|
|
195
278
|
id: r.id,
|
|
@@ -222,6 +305,282 @@ export class ConnectionRegistry {
|
|
|
222
305
|
return new ConnectionRegistry(this.catalog, async () => db, this.secrets);
|
|
223
306
|
}
|
|
224
307
|
|
|
308
|
+
/** Safe card data only. Listing does not read credential values or contact a vendor. */
|
|
309
|
+
async cards(actor: string): Promise<ConnectionCard[]> {
|
|
310
|
+
const rows = (
|
|
311
|
+
await (
|
|
312
|
+
await this.db()
|
|
313
|
+
).query<ConnectionRow>(
|
|
314
|
+
"SELECT c.*, h.health, h.checked_at, h.externally_renewed FROM sanoma_connections c LEFT JOIN sanoma_connection_health h ON c.id=h.connection_id AND c.revision=h.revision WHERE c.owner IS NULL OR c.owner=$1",
|
|
315
|
+
[actor],
|
|
316
|
+
)
|
|
317
|
+
).rows;
|
|
318
|
+
const found = new Map(rows.map((r) => [r.id, r]));
|
|
319
|
+
const ids = new Set([...this.catalog.definitions.keys(), ...rows.filter((r) => r.rule).map((r) => r.id)]);
|
|
320
|
+
const cards: ConnectionCard[] = [];
|
|
321
|
+
for (const id of ids) {
|
|
322
|
+
const row = found.get(id);
|
|
323
|
+
const declaration = row?.rule ? this.catalog.rules.get(row.rule) : this.catalog.definitions.get(id);
|
|
324
|
+
if (!declaration || (row?.rule && row.owner !== actor)) continue;
|
|
325
|
+
const method = this.catalog.methods.get(declaration.vendor)?.[declaration.method];
|
|
326
|
+
if (!method) continue;
|
|
327
|
+
const credential =
|
|
328
|
+
method.kind === "workload-identity"
|
|
329
|
+
? undefined
|
|
330
|
+
: "default" in declaration
|
|
331
|
+
? declaration.credentials?.$secret
|
|
332
|
+
: row?.credential;
|
|
333
|
+
cards.push({
|
|
334
|
+
id,
|
|
335
|
+
vendor: declaration.vendor,
|
|
336
|
+
name: row?.rule ? id.slice(declaration.vendor.length + 1) : declaration.name,
|
|
337
|
+
method: declaration.method,
|
|
338
|
+
...(row && { account: row.account, revision: row.revision }),
|
|
339
|
+
scopes: row?.scopes ?? [],
|
|
340
|
+
requiredScopes: declaration.scopes,
|
|
341
|
+
...("account" in declaration && declaration.account && { expectedAccount: declaration.account }),
|
|
342
|
+
...(row?.owner && { owner: row.owner }),
|
|
343
|
+
status: !row
|
|
344
|
+
? "missing"
|
|
345
|
+
: row.definition !== this.bindingFingerprint(declaration, method, credential)
|
|
346
|
+
? "configuration_changed"
|
|
347
|
+
: row.status,
|
|
348
|
+
...(credential && this.secrets && { location: this.secrets.location(credential) }),
|
|
349
|
+
...(row?.location && { boundLocation: row.location }),
|
|
350
|
+
...(row?.updated_by && { updatedBy: row.updated_by }),
|
|
351
|
+
...(row?.updated_at && { updatedAt: row.updated_at.toISOString() }),
|
|
352
|
+
...(row?.health &&
|
|
353
|
+
row.checked_at && {
|
|
354
|
+
health: {
|
|
355
|
+
state: row.health,
|
|
356
|
+
checkedAt: row.checked_at.toISOString(),
|
|
357
|
+
externallyRenewed: row.externally_renewed ?? false,
|
|
358
|
+
},
|
|
359
|
+
}),
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
return cards;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Resolve code-defined setup authority without reading a credential. The request id stays
|
|
366
|
+
* stable across preview, approval and retry; account allocation is scoped to its principal. */
|
|
367
|
+
async setup(target: ConnectionTarget, actor: Principal, requestId: string): Promise<ConnectionSetup> {
|
|
368
|
+
let declaration: ConnectionDefinition | ConnectionRule | undefined;
|
|
369
|
+
let row: ConnectionRow | undefined;
|
|
370
|
+
let id: string;
|
|
371
|
+
let credential: string | undefined;
|
|
372
|
+
if ("rule" in target) {
|
|
373
|
+
const rule = this.catalog.rules.get(target.rule);
|
|
374
|
+
if (
|
|
375
|
+
!rule ||
|
|
376
|
+
!rule.createBy.some((by) => (typeof by === "string" ? by === actor.id : actor.groups?.includes(by.group)))
|
|
377
|
+
)
|
|
378
|
+
throw new SanomaError("policy_denied", "This principal cannot create connections under this rule");
|
|
379
|
+
declaration = rule;
|
|
380
|
+
const allocation = createHash("sha256")
|
|
381
|
+
.update(JSON.stringify([actor.id, target.rule, requestId]))
|
|
382
|
+
.digest("hex");
|
|
383
|
+
id = `${target.rule}/${allocation}`;
|
|
384
|
+
credential = `CONNECTION_${allocation.toUpperCase()}`;
|
|
385
|
+
row = await this.row(id);
|
|
386
|
+
} else {
|
|
387
|
+
id = target.connection.$connection;
|
|
388
|
+
row = await this.row(id);
|
|
389
|
+
declaration = row?.rule ? this.catalog.rules.get(row.rule) : this.catalog.definitions.get(id);
|
|
390
|
+
if (!declaration || declaration.vendor !== target.connection.vendor) refuse("The connection is not declared");
|
|
391
|
+
if (row?.rule && row.owner !== actor.id)
|
|
392
|
+
throw new SanomaError("policy_denied", "The connection belongs to another principal");
|
|
393
|
+
credential = "credentials" in declaration ? declaration.credentials?.$secret : (row?.credential ?? undefined);
|
|
394
|
+
}
|
|
395
|
+
const method = this.catalog.methods.get(declaration.vendor)?.[declaration.method];
|
|
396
|
+
if (!method?.verify) throw new AuthenticationError("unsupported");
|
|
397
|
+
if (method.kind === "workload-identity") credential = undefined;
|
|
398
|
+
this.checkStore(method, credential);
|
|
399
|
+
return {
|
|
400
|
+
target,
|
|
401
|
+
id,
|
|
402
|
+
vendor: declaration.vendor,
|
|
403
|
+
method: declaration.method,
|
|
404
|
+
definition: this.bindingFingerprint(declaration, method, credential),
|
|
405
|
+
...(credential && { credential, ...(this.secrets && { location: this.secrets.location(credential) }) }),
|
|
406
|
+
expectedRevision: row?.revision ?? null,
|
|
407
|
+
requiredScopes: declaration.scopes,
|
|
408
|
+
...("account" in declaration && declaration.account && { expectedAccount: declaration.account }),
|
|
409
|
+
...(row && { previousAccount: row.account }),
|
|
410
|
+
...(row?.location && { previousLocation: row.location }),
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Metadata-only, policy-authorized reservation. Calls cannot use it until activation verifies
|
|
415
|
+
* the saved bundle. A store write failure leaves an explicit uncertain, unusable account. */
|
|
416
|
+
async configure(
|
|
417
|
+
target: ConnectionTarget,
|
|
418
|
+
requestId: string,
|
|
419
|
+
expectedRevision: string | null,
|
|
420
|
+
definition: string,
|
|
421
|
+
account: string,
|
|
422
|
+
scopes: string[],
|
|
423
|
+
actor: Principal,
|
|
424
|
+
): Promise<ConnectionSnapshot> {
|
|
425
|
+
const setup = await this.setup(target, actor, requestId);
|
|
426
|
+
if (setup.definition !== definition || setup.expectedRevision !== expectedRevision)
|
|
427
|
+
refuse("The connection changed; review it again before saving");
|
|
428
|
+
if (setup.expectedAccount && setup.expectedAccount !== account) throw new AuthenticationError("wrong_account");
|
|
429
|
+
if (setup.requiredScopes.some((s) => !scopes.includes(s))) throw new AuthenticationError("scope_changed");
|
|
430
|
+
const current = await this.row(setup.id);
|
|
431
|
+
const row: ConnectionRow = {
|
|
432
|
+
id: setup.id,
|
|
433
|
+
vendor: setup.vendor,
|
|
434
|
+
method: setup.method,
|
|
435
|
+
owner: "rule" in target ? actor.id : (current?.owner ?? null),
|
|
436
|
+
rule: "rule" in target ? target.rule : (current?.rule ?? null),
|
|
437
|
+
account,
|
|
438
|
+
scopes,
|
|
439
|
+
revision: randomUUID(),
|
|
440
|
+
definition,
|
|
441
|
+
credential: setup.credential ?? null,
|
|
442
|
+
status: "pending",
|
|
443
|
+
};
|
|
444
|
+
await this.save(row, expectedRevision, actor.id);
|
|
445
|
+
return snapshot(row);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async uncertain(ref: ConnectionRef, revision: string, actor: string): Promise<void> {
|
|
449
|
+
await (
|
|
450
|
+
await this.db()
|
|
451
|
+
).query(
|
|
452
|
+
"UPDATE sanoma_connections SET status='uncertain',updated_by=$3,updated_at=now() WHERE id=$1 AND revision=$2 AND status='pending'",
|
|
453
|
+
[ref.$connection, revision, actor],
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async storedVersion(id: string, revision: string): Promise<string | undefined> {
|
|
458
|
+
return (
|
|
459
|
+
await (
|
|
460
|
+
await this.db()
|
|
461
|
+
).query<{ credential_version: string }>(
|
|
462
|
+
"SELECT credential_version FROM sanoma_connection_setups WHERE connection_id=$1 AND revision=$2",
|
|
463
|
+
[id, revision],
|
|
464
|
+
)
|
|
465
|
+
).rows[0]?.credential_version;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async recordStoredVersion(id: string, revision: string, version: string): Promise<void> {
|
|
469
|
+
await (
|
|
470
|
+
await this.db()
|
|
471
|
+
).query(
|
|
472
|
+
"INSERT INTO sanoma_connection_setups (connection_id,revision,credential_version) SELECT id,revision,$3 FROM sanoma_connections WHERE id=$1 AND revision=$2 AND status='pending' ON CONFLICT DO NOTHING",
|
|
473
|
+
[id, revision, version],
|
|
474
|
+
);
|
|
475
|
+
if ((await this.storedVersion(id, revision)) !== version) throw new SecretStoreError("conflict");
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
private async health(
|
|
479
|
+
id: string,
|
|
480
|
+
revision: string,
|
|
481
|
+
state: ConnectionHealth["state"],
|
|
482
|
+
version?: string,
|
|
483
|
+
external = false,
|
|
484
|
+
): Promise<ConnectionHealth> {
|
|
485
|
+
const previous = (
|
|
486
|
+
await (
|
|
487
|
+
await this.db()
|
|
488
|
+
).query<{ credential_version: string | null }>(
|
|
489
|
+
"SELECT credential_version FROM sanoma_connection_health WHERE connection_id=$1 AND revision=$2",
|
|
490
|
+
[id, revision],
|
|
491
|
+
)
|
|
492
|
+
).rows[0];
|
|
493
|
+
const renewed =
|
|
494
|
+
external &&
|
|
495
|
+
previous?.credential_version != null &&
|
|
496
|
+
version !== undefined &&
|
|
497
|
+
previous.credential_version !== version;
|
|
498
|
+
const result = await (
|
|
499
|
+
await this.db()
|
|
500
|
+
).query<{ checked_at: Date }>(
|
|
501
|
+
"INSERT INTO sanoma_connection_health (connection_id,revision,health,credential_version,externally_renewed) SELECT id,revision,$3,$4,$5 FROM sanoma_connections WHERE id=$1 AND revision=$2 ON CONFLICT(connection_id) DO UPDATE SET revision=excluded.revision,health=excluded.health,credential_version=excluded.credential_version,externally_renewed=excluded.externally_renewed,checked_at=now() RETURNING checked_at",
|
|
502
|
+
[id, revision, state, version ?? previous?.credential_version ?? null, renewed],
|
|
503
|
+
);
|
|
504
|
+
const checked = result.rows[0];
|
|
505
|
+
if (!checked) refuse("The connection changed during the test; reload before testing it again");
|
|
506
|
+
return { state, checkedAt: checked.checked_at.toISOString(), externallyRenewed: renewed };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async test(ref: ConnectionRef, actor: Principal): Promise<ConnectionHealth> {
|
|
510
|
+
const setup = await this.setup({ connection: ref }, actor, "test");
|
|
511
|
+
const row = await this.row(setup.id);
|
|
512
|
+
if (!row) return { state: "missing", checkedAt: new Date().toISOString(), externallyRenewed: false };
|
|
513
|
+
if (row.definition !== setup.definition)
|
|
514
|
+
return { state: "configuration_changed", checkedAt: new Date().toISOString(), externallyRenewed: false };
|
|
515
|
+
if (row.status !== "connected") return this.health(row.id, row.revision, row.status);
|
|
516
|
+
try {
|
|
517
|
+
const material = await this.authenticate(snapshot(row), actor.id);
|
|
518
|
+
return this.health(
|
|
519
|
+
row.id,
|
|
520
|
+
row.revision,
|
|
521
|
+
"connected",
|
|
522
|
+
material.credentialVersion,
|
|
523
|
+
setup.location?.management === "external" || !this.secrets?.store.capabilities.writable,
|
|
524
|
+
);
|
|
525
|
+
} catch (error) {
|
|
526
|
+
const code = (error as { code?: string })?.code;
|
|
527
|
+
const state =
|
|
528
|
+
authenticationProblem(error) ??
|
|
529
|
+
(code === "denied" ? "store_denied" : code === "invalid_value" ? "invalid" : "unavailable");
|
|
530
|
+
return this.health(row.id, row.revision, state);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
async setupState(selected: ConnectionSnapshot, actor: Principal): Promise<"pending" | "connected"> {
|
|
535
|
+
const setup = await this.setup(
|
|
536
|
+
{ connection: { vendor: selected.vendor, $connection: selected.id } },
|
|
537
|
+
actor,
|
|
538
|
+
"state",
|
|
539
|
+
);
|
|
540
|
+
const row = await this.row(selected.id);
|
|
541
|
+
if (
|
|
542
|
+
!row ||
|
|
543
|
+
row.definition !== setup.definition ||
|
|
544
|
+
row.account !== selected.account ||
|
|
545
|
+
JSON.stringify(row.scopes.toSorted()) !== JSON.stringify(selected.scopes.toSorted())
|
|
546
|
+
)
|
|
547
|
+
refuse("The connection changed; review it again before saving");
|
|
548
|
+
if (row.status === "connected") return "connected";
|
|
549
|
+
if (row.status !== "pending" || row.revision !== selected.revision)
|
|
550
|
+
refuse("The connection changed; review it again before saving");
|
|
551
|
+
return "pending";
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** Used inside an authorized operation; verification reads stay inside its driver step. */
|
|
555
|
+
async activate(ref: ConnectionRef, revision: string, actor: Principal): Promise<ConnectionSnapshot> {
|
|
556
|
+
const setup = await this.setup({ connection: ref }, actor, "activate");
|
|
557
|
+
const row = await this.row(setup.id);
|
|
558
|
+
if (
|
|
559
|
+
!row ||
|
|
560
|
+
row.revision !== revision ||
|
|
561
|
+
row.definition !== setup.definition ||
|
|
562
|
+
!["pending", "uncertain"].includes(row.status)
|
|
563
|
+
)
|
|
564
|
+
refuse("The connection changed; review it again before activating");
|
|
565
|
+
const method = this.catalog.methods.get(row.vendor)![row.method]!;
|
|
566
|
+
const bundle = row.credential ? await this.secrets?.read(row.credential) : undefined;
|
|
567
|
+
if (row.credential && !bundle) throw new SecretStoreError("unavailable");
|
|
568
|
+
const identity = await verifyAuthentication(method, bundle && decodeCredentials(method, bundle.value));
|
|
569
|
+
if (identity.account !== row.account) throw new AuthenticationError("wrong_account");
|
|
570
|
+
if (JSON.stringify(identity.scopes) !== JSON.stringify([...new Set(row.scopes)].toSorted()))
|
|
571
|
+
throw new AuthenticationError("scope_changed");
|
|
572
|
+
const next = randomUUID();
|
|
573
|
+
const result = await (
|
|
574
|
+
await this.db()
|
|
575
|
+
).query(
|
|
576
|
+
"UPDATE sanoma_connections SET status='connected',revision=$3,updated_by=$4,updated_at=now() WHERE id=$1 AND revision=$2 RETURNING id",
|
|
577
|
+
[row.id, revision, next, actor.id],
|
|
578
|
+
);
|
|
579
|
+
if (!result.rowCount) refuse("The connection changed; review it again before activating");
|
|
580
|
+
await this.health(row.id, next, "connected", bundle?.version);
|
|
581
|
+
return snapshot({ ...row, revision: next, status: "connected" });
|
|
582
|
+
}
|
|
583
|
+
|
|
225
584
|
private bindingFingerprint(
|
|
226
585
|
declaration: ConnectionDefinition | ConnectionRule,
|
|
227
586
|
method: Authentication,
|
|
@@ -286,10 +645,40 @@ export class ConnectionRegistry {
|
|
|
286
645
|
async assertCurrent(selected: ConnectionSnapshot | undefined, actor: string): Promise<void> {
|
|
287
646
|
if (!selected) return;
|
|
288
647
|
const current = await this.select(selected.vendor, { vendor: selected.vendor, $connection: selected.id }, actor);
|
|
289
|
-
if (
|
|
648
|
+
if (
|
|
649
|
+
!current ||
|
|
650
|
+
current.revision !== selected.revision ||
|
|
651
|
+
current.definition !== selected.definition ||
|
|
652
|
+
current.method !== selected.method ||
|
|
653
|
+
current.account !== selected.account ||
|
|
654
|
+
current.credentials?.$secret !== selected.credentials?.$secret ||
|
|
655
|
+
JSON.stringify(current.scopes.toSorted()) !== JSON.stringify(selected.scopes.toSorted())
|
|
656
|
+
)
|
|
290
657
|
refuse("The connection changed after authorization; start a new call");
|
|
291
658
|
}
|
|
292
659
|
|
|
660
|
+
/** Only called inside an authorized driver boundary. No value escapes into workflow history. */
|
|
661
|
+
async authenticate(selected: ConnectionSnapshot, actor: string): Promise<CallAuthentication> {
|
|
662
|
+
await this.assertCurrent(selected, actor);
|
|
663
|
+
const method = this.catalog.methods.get(selected.vendor)?.[selected.method];
|
|
664
|
+
if (!method?.verify) throw new AuthenticationError("unsupported");
|
|
665
|
+
const material: CallAuthentication = { method: selected.method, kind: method.kind };
|
|
666
|
+
if (method.kind !== "workload-identity") {
|
|
667
|
+
if (!this.secrets || !selected.credentials) throw new SecretStoreError("unavailable");
|
|
668
|
+
const bundle = await this.secrets.read(selected.credentials.$secret);
|
|
669
|
+
if (!bundle) throw new AuthenticationError("missing");
|
|
670
|
+
material.credentials = decodeCredentials(method, bundle.value);
|
|
671
|
+
material.credentialVersion = bundle.version;
|
|
672
|
+
}
|
|
673
|
+
const identity = await verifyAuthentication(method, material.credentials);
|
|
674
|
+
if (identity.account !== selected.account) throw new AuthenticationError("wrong_account");
|
|
675
|
+
if (JSON.stringify(identity.scopes) !== JSON.stringify([...new Set(selected.scopes)].toSorted()))
|
|
676
|
+
throw new AuthenticationError("scope_changed");
|
|
677
|
+
// A disconnect or rebind while verification was in flight revokes this invocation too.
|
|
678
|
+
await this.assertCurrent(selected, actor);
|
|
679
|
+
return material;
|
|
680
|
+
}
|
|
681
|
+
|
|
293
682
|
async bind(
|
|
294
683
|
ref: ConnectionRef,
|
|
295
684
|
account: string,
|
|
@@ -395,11 +784,13 @@ export class ConnectionRegistry {
|
|
|
395
784
|
row.definition,
|
|
396
785
|
row.credential,
|
|
397
786
|
actor,
|
|
787
|
+
row.status,
|
|
788
|
+
row.credential && this.secrets ? JSON.stringify(this.secrets.location(row.credential)) : null,
|
|
398
789
|
];
|
|
399
790
|
const query =
|
|
400
791
|
expected === null
|
|
401
|
-
? "INSERT INTO sanoma_connections (id,vendor,method,owner,rule,account,scopes,revision,definition,credential,updated_by) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) ON CONFLICT(id) DO NOTHING RETURNING id"
|
|
402
|
-
: "UPDATE sanoma_connections SET vendor=$2,method=$3,owner=$4,rule=$5,account=$6,scopes=$7,revision=$8,definition=$9,credential=$10,updated_by=$11,updated_at=now(),status
|
|
792
|
+
? "INSERT INTO sanoma_connections (id,vendor,method,owner,rule,account,scopes,revision,definition,credential,updated_by,status,location) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ON CONFLICT(id) DO NOTHING RETURNING id"
|
|
793
|
+
: "UPDATE sanoma_connections SET vendor=$2,method=$3,owner=$4,rule=$5,account=$6,scopes=$7,revision=$8,definition=$9,credential=$10,updated_by=$11,updated_at=now(),status=$12,location=$13 WHERE id=$1 AND revision=$14 RETURNING id";
|
|
403
794
|
const result = await (await this.db()).query(query, [...values, ...(expected === null ? [] : [expected])]);
|
|
404
795
|
if (!result.rowCount) refuse("The connection changed; reload before binding it");
|
|
405
796
|
}
|
|
@@ -408,6 +799,64 @@ export class ConnectionRegistry {
|
|
|
408
799
|
/** Metadata mutations are access operations, so they are policy-reviewed and audited like other effects. */
|
|
409
800
|
export const connectionOps = defineConnector("sanoma", {
|
|
410
801
|
connection: {
|
|
802
|
+
test: {
|
|
803
|
+
effect: "read",
|
|
804
|
+
input: z.object({ connection: connectionRefSchema }),
|
|
805
|
+
output: z.object({
|
|
806
|
+
state: z.enum([
|
|
807
|
+
"missing",
|
|
808
|
+
"expired",
|
|
809
|
+
"revoked",
|
|
810
|
+
"rejected",
|
|
811
|
+
"wrong_account",
|
|
812
|
+
"scope_changed",
|
|
813
|
+
"uncertain",
|
|
814
|
+
"unavailable",
|
|
815
|
+
"unsupported",
|
|
816
|
+
"connected",
|
|
817
|
+
"disconnected",
|
|
818
|
+
"pending",
|
|
819
|
+
"configuration_changed",
|
|
820
|
+
"store_denied",
|
|
821
|
+
"invalid",
|
|
822
|
+
]),
|
|
823
|
+
checkedAt: z.string(),
|
|
824
|
+
externallyRenewed: z.boolean(),
|
|
825
|
+
}),
|
|
826
|
+
target: (input: { connection: ConnectionRef }) => input.connection.$connection,
|
|
827
|
+
},
|
|
828
|
+
prepare: {
|
|
829
|
+
effect: "access",
|
|
830
|
+
input: z.object({
|
|
831
|
+
target: connectionTargetSchema,
|
|
832
|
+
requestId: z.string().min(1),
|
|
833
|
+
definition: z.string(),
|
|
834
|
+
authorization: z.string(),
|
|
835
|
+
}),
|
|
836
|
+
output: setupSchema,
|
|
837
|
+
target: (input: { target: ConnectionTarget }) =>
|
|
838
|
+
"connection" in input.target ? input.target.connection.$connection : input.target.rule,
|
|
839
|
+
},
|
|
840
|
+
configure: {
|
|
841
|
+
effect: "access",
|
|
842
|
+
input: z.object({
|
|
843
|
+
target: connectionTargetSchema,
|
|
844
|
+
requestId: z.string().min(1),
|
|
845
|
+
expectedRevision: z.string().nullable(),
|
|
846
|
+
definition: z.string(),
|
|
847
|
+
account: z.string().min(1),
|
|
848
|
+
scopes: z.array(z.string()),
|
|
849
|
+
}),
|
|
850
|
+
output: snapshotSchema,
|
|
851
|
+
target: (input: { target: ConnectionTarget }) =>
|
|
852
|
+
"connection" in input.target ? input.target.connection.$connection : input.target.rule,
|
|
853
|
+
},
|
|
854
|
+
activate: {
|
|
855
|
+
effect: "access",
|
|
856
|
+
input: z.object({ connection: connectionRefSchema, expectedRevision: z.string() }),
|
|
857
|
+
output: snapshotSchema,
|
|
858
|
+
target: (input: { connection: ConnectionRef }) => input.connection.$connection,
|
|
859
|
+
},
|
|
411
860
|
disconnect: {
|
|
412
861
|
effect: "access",
|
|
413
862
|
input: z.object({ connection: connectionRefSchema, expectedRevision: z.string() }),
|
|
@@ -437,6 +886,23 @@ const actor = (call: CallContext) => call.principal ?? refuse("Connection mutati
|
|
|
437
886
|
export function connectionDriver(registry: ConnectionRegistry) {
|
|
438
887
|
return defineDriver(connectionOps, {
|
|
439
888
|
connection: {
|
|
889
|
+
test: (input, call) => registry.test(input.connection, actor(call)),
|
|
890
|
+
prepare: async (input, call) => {
|
|
891
|
+
const setup = await registry.setup(input.target, actor(call), input.requestId);
|
|
892
|
+
if (setup.definition !== input.definition) refuse("The connection changed; review its requirements again");
|
|
893
|
+
return { ...setup, requiredScopes: [...setup.requiredScopes] };
|
|
894
|
+
},
|
|
895
|
+
configure: (input, call) =>
|
|
896
|
+
registry.configure(
|
|
897
|
+
input.target,
|
|
898
|
+
input.requestId,
|
|
899
|
+
input.expectedRevision,
|
|
900
|
+
input.definition,
|
|
901
|
+
input.account,
|
|
902
|
+
input.scopes,
|
|
903
|
+
actor(call),
|
|
904
|
+
),
|
|
905
|
+
activate: (input, call) => registry.activate(input.connection, input.expectedRevision, actor(call)),
|
|
440
906
|
disconnect: (input, call) => registry.disconnect(input.connection, input.expectedRevision, actor(call).id),
|
|
441
907
|
bind: (input, call) =>
|
|
442
908
|
registry.bind(input.connection, input.account, input.scopes, input.expectedRevision, actor(call).id),
|
package/src/db.ts
CHANGED
|
@@ -31,13 +31,26 @@ export const CHAT_MESSAGES_TABLE = "sanoma_chat_messages";
|
|
|
31
31
|
|
|
32
32
|
/** The runtime's tables, each with the word the messages name it by. */
|
|
33
33
|
const TABLES: readonly [what: string, ddl: string][] = [
|
|
34
|
+
[
|
|
35
|
+
"connection health",
|
|
36
|
+
`CREATE TABLE IF NOT EXISTS sanoma_connection_health (
|
|
37
|
+
connection_id text PRIMARY KEY, revision text NOT NULL, health text NOT NULL,
|
|
38
|
+
credential_version text, externally_renewed boolean NOT NULL, checked_at timestamptz NOT NULL DEFAULT now())`,
|
|
39
|
+
],
|
|
40
|
+
[
|
|
41
|
+
"connection setup receipts",
|
|
42
|
+
`CREATE TABLE IF NOT EXISTS sanoma_connection_setups (
|
|
43
|
+
connection_id text NOT NULL, revision text NOT NULL, credential_version text NOT NULL,
|
|
44
|
+
PRIMARY KEY(connection_id, revision))`,
|
|
45
|
+
],
|
|
34
46
|
[
|
|
35
47
|
"connections",
|
|
36
48
|
`CREATE TABLE IF NOT EXISTS sanoma_connections (
|
|
37
49
|
id text PRIMARY KEY, vendor text NOT NULL, method text NOT NULL, owner text, rule text,
|
|
38
50
|
account text NOT NULL, scopes jsonb NOT NULL, revision text NOT NULL, definition text NOT NULL,
|
|
39
|
-
credential text, status text NOT NULL DEFAULT 'connected', updated_by text NOT NULL,
|
|
40
|
-
updated_at timestamptz NOT NULL DEFAULT now())
|
|
51
|
+
credential text, location jsonb, status text NOT NULL DEFAULT 'connected', updated_by text NOT NULL,
|
|
52
|
+
updated_at timestamptz NOT NULL DEFAULT now());
|
|
53
|
+
ALTER TABLE sanoma_connections ADD COLUMN IF NOT EXISTS location jsonb`,
|
|
41
54
|
],
|
|
42
55
|
[
|
|
43
56
|
"credentials",
|
package/src/describe.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
1
2
|
import type { z } from "zod";
|
|
2
3
|
import { type AppEntry, appsAt } from "./apps.ts";
|
|
3
4
|
import { readSource, type SourceFile } from "./ast.ts";
|
|
@@ -17,13 +18,16 @@ import {
|
|
|
17
18
|
import { isDirect } from "./direct.ts";
|
|
18
19
|
import { type CredentialStatus, credentialsOf } from "./env.ts";
|
|
19
20
|
import { lintWorkflow } from "./lint.ts";
|
|
21
|
+
import { fresh, stampOf, stampsOf } from "./load.ts";
|
|
22
|
+
import { LOCKFILE } from "./lockfile.ts";
|
|
20
23
|
import { VENDOR, type VendorInfo } from "./op.ts";
|
|
21
24
|
import { outlineWithSource } from "./outline.ts";
|
|
25
|
+
import { APPS_DIR, kindDirs } from "./paths.ts";
|
|
22
26
|
import { allowAll, policyOpOf } from "./policy.ts";
|
|
23
27
|
import { type Resource, resourceTypesOf, valueAt } from "./resource.ts";
|
|
24
28
|
import { type DeclaredResource, inAppPeople, loadDataFiles, type ResourceProblem } from "./resources.ts";
|
|
25
29
|
import { apiNameProblem } from "./sdk.ts";
|
|
26
|
-
import { apiNameOf, BUILT_IN_WORKFLOWS, DATA_VENDOR, problemLine } from "./shared.ts";
|
|
30
|
+
import { apiNameOf, BUILT_IN_WORKFLOWS, DATA_VENDOR, errorMessage, problemLine } from "./shared.ts";
|
|
27
31
|
import { readSites, type SiteEntry } from "./sites.ts";
|
|
28
32
|
|
|
29
33
|
// `@sanoma-ai/workflows/describe`: what a UI renders from, apart from the main entry.
|
|
@@ -79,6 +83,53 @@ export { previewRequests } from "./shared.ts";
|
|
|
79
83
|
// The apply workflow's plan and report, which the app renders.
|
|
80
84
|
export type { ApplyInput, ApplyPlan, ApplyReport, ApplyResult, PlanAnswer, PlanStep } from "./apply.ts";
|
|
81
85
|
|
|
86
|
+
/**
|
|
87
|
+
* `describeConfig(config)`, kept and made again only once the files it loaded have changed: each
|
|
88
|
+
* company file a load read and its folder (`stampsOf`), the lockfile, and the folders a new data
|
|
89
|
+
* file, app or site would appear in, one `stat` each per call. What the config imports (its
|
|
90
|
+
* workflows, connectors and policies) is read once, as the worker reads it. When describing again
|
|
91
|
+
* throws, the last description stands until the files change again.
|
|
92
|
+
*/
|
|
93
|
+
export function liveDescription(config: SanomaConfig): () => ConfigDescription {
|
|
94
|
+
const root = rootOf(config);
|
|
95
|
+
if (root === undefined) {
|
|
96
|
+
const description = describeConfig(config);
|
|
97
|
+
return () => description;
|
|
98
|
+
}
|
|
99
|
+
/** The description and what it read; `last`, kept (and said) when describing again throws. */
|
|
100
|
+
const described = (last?: ConfigDescription) => {
|
|
101
|
+
// The folders and the lockfile as they are before describing, so a change while it reads is seen after.
|
|
102
|
+
const extra = [
|
|
103
|
+
resolve(root, LOCKFILE),
|
|
104
|
+
resolve(root, APPS_DIR),
|
|
105
|
+
...kindDirs(root, "sites"),
|
|
106
|
+
...kindDirs(root, "resources"),
|
|
107
|
+
].map((path) => [path, stampOf(path)] as const);
|
|
108
|
+
let description: ConfigDescription;
|
|
109
|
+
try {
|
|
110
|
+
description = describeConfig(config);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
if (last === undefined) throw err;
|
|
113
|
+
console.warn(
|
|
114
|
+
`sanoma: the files could not be described again, so the last description stands: ${errorMessage(err)}`,
|
|
115
|
+
);
|
|
116
|
+
description = last;
|
|
117
|
+
}
|
|
118
|
+
return { description, stamps: new Map([...extra, ...stampsOf(root)]) };
|
|
119
|
+
};
|
|
120
|
+
let now = described();
|
|
121
|
+
// The workflows as the config imported them, which the worker runs: their outlines and sources
|
|
122
|
+
// from the first description, never a file edited since.
|
|
123
|
+
const { workflows } = now.description;
|
|
124
|
+
return () => {
|
|
125
|
+
if (!fresh(now.stamps)) {
|
|
126
|
+
now = described(now.description);
|
|
127
|
+
now.description.workflows = workflows;
|
|
128
|
+
}
|
|
129
|
+
return now.description;
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
82
133
|
/**
|
|
83
134
|
* Describes a config. Throws what `startWorker` would refuse (see `resolveConfig`), except missing
|
|
84
135
|
* credentials (`SanomaClient.credentials` gives their statuses) and what is wrong in a workflow's
|
package/src/direct.ts
CHANGED
|
@@ -19,4 +19,8 @@ export const isDirect = (
|
|
|
19
19
|
op.id === "sanoma.connection.disconnect" ||
|
|
20
20
|
op.id === "sanoma.connection.bind" ||
|
|
21
21
|
op.id === "sanoma.connection.create" ||
|
|
22
|
+
op.id === "sanoma.connection.prepare" ||
|
|
23
|
+
op.id === "sanoma.connection.configure" ||
|
|
24
|
+
op.id === "sanoma.connection.activate" ||
|
|
25
|
+
op.id === "sanoma.connection.test" ||
|
|
22
26
|
(op.effect === "read" && op.vendor !== "sanoma" && !isResourceOp(op, types));
|