@sanoma-ai/workflows 0.11.12 → 0.11.14
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 +12 -4
- package/KMS-SECRETS.md +3 -1
- package/README.md +4 -2
- package/dist/bundled.d.ts +2 -2
- package/dist/chats.d.ts +3 -3
- package/dist/chats.js +34 -20
- package/dist/chats.js.map +1 -1
- package/dist/client.d.ts +17 -4
- package/dist/client.js +74 -63
- package/dist/client.js.map +1 -1
- package/dist/cloud-identity.js +14 -12
- package/dist/cloud-identity.js.map +1 -1
- package/dist/collection.d.ts +2 -2
- package/dist/collection.js +53 -42
- package/dist/collection.js.map +1 -1
- package/dist/config.d.ts +11 -6
- package/dist/config.js +15 -13
- package/dist/config.js.map +1 -1
- package/dist/connection.d.ts +2 -0
- package/dist/connection.js +112 -40
- package/dist/connection.js.map +1 -1
- package/dist/credentials.d.ts +1 -1
- package/dist/credentials.js +33 -29
- package/dist/credentials.js.map +1 -1
- package/dist/db/company.d.ts +1152 -0
- package/dist/db/company.js +118 -0
- package/dist/db/company.js.map +1 -0
- package/dist/db/dbos.d.ts +111 -0
- package/dist/db/dbos.js +15 -0
- package/dist/db/dbos.js.map +1 -0
- package/dist/db/identity.d.ts +367 -0
- package/dist/db/identity.js +48 -0
- package/dist/db/identity.js.map +1 -0
- package/dist/db/index.d.ts +7 -0
- package/dist/db/index.js +12 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/kms.d.ts +224 -0
- package/dist/db/kms.js +29 -0
- package/dist/db/kms.js.map +1 -0
- package/dist/db/migrate.d.ts +66 -0
- package/dist/db/migrate.js +39 -0
- package/dist/db/migrate.js.map +1 -0
- package/dist/db/wakeup.d.ts +271 -0
- package/dist/db/wakeup.js +34 -0
- package/dist/db/wakeup.js.map +1 -0
- package/dist/db.d.ts +42 -39
- package/dist/db.js +129 -169
- package/dist/db.js.map +1 -1
- package/dist/deadlines.js +8 -4
- package/dist/deadlines.js.map +1 -1
- package/dist/deployment-key.d.ts +4 -1
- package/dist/deployment-key.js +10 -3
- package/dist/deployment-key.js.map +1 -1
- package/dist/host-derive.d.ts +12 -3
- package/dist/host-derive.js +21 -8
- package/dist/host-derive.js.map +1 -1
- package/dist/identity-provisioning.d.ts +2 -2
- package/dist/identity-provisioning.js +75 -42
- package/dist/identity-provisioning.js.map +1 -1
- package/dist/identity-service.js +18 -15
- package/dist/identity-service.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/kms-secrets.d.ts +9 -0
- package/dist/kms-secrets.js +59 -27
- package/dist/kms-secrets.js.map +1 -1
- package/dist/ledger.d.ts +1 -1
- package/dist/ledger.js +22 -14
- package/dist/ledger.js.map +1 -1
- package/dist/op.d.ts +27 -6
- package/dist/op.js.map +1 -1
- package/dist/run-status.js +7 -3
- package/dist/run-status.js.map +1 -1
- package/dist/schedules.d.ts +1 -1
- package/dist/schedules.js +5 -7
- package/dist/schedules.js.map +1 -1
- package/dist/schema.d.ts +6 -5
- package/dist/schema.js +11 -7
- package/dist/schema.js.map +1 -1
- package/dist/shared.d.ts +6 -23
- package/dist/shared.js +6 -5
- package/dist/shared.js.map +1 -1
- package/dist/storage-provisioning.d.ts +2 -0
- package/dist/storage-provisioning.js +51 -50
- package/dist/storage-provisioning.js.map +1 -1
- package/dist/tokens.d.ts +2 -2
- package/dist/tokens.js +31 -20
- package/dist/tokens.js.map +1 -1
- package/dist/version.d.ts +4 -4
- package/dist/version.js +5 -5
- package/dist/version.js.map +1 -1
- package/dist/wakeup-service.d.ts +0 -4
- package/dist/wakeup-service.js +102 -93
- package/dist/wakeup-service.js.map +1 -1
- package/dist/worker.d.ts +7 -0
- package/dist/worker.js +26 -21
- package/dist/worker.js.map +1 -1
- package/drizzle/company/0000_tables.sql +104 -0
- package/drizzle/company/0001_wakeup_row.sql +2 -0
- package/drizzle/company/meta/0000_snapshot.json +652 -0
- package/drizzle/company/meta/0001_snapshot.json +652 -0
- package/drizzle/company/meta/_journal.json +20 -0
- package/drizzle/wakeup/0000_tables.sql +26 -0
- package/drizzle/wakeup/meta/0000_snapshot.json +200 -0
- package/drizzle/wakeup/meta/_journal.json +13 -0
- package/oxlint.json +2 -1
- package/package.json +11 -2
- package/src/bundled.ts +2 -2
- package/src/chats.ts +41 -42
- package/src/client.ts +81 -64
- package/src/cloud-identity.ts +23 -24
- package/src/collection.ts +58 -50
- package/src/config.ts +29 -19
- package/src/connection.ts +132 -102
- package/src/credentials.ts +37 -33
- package/src/db/company.ts +173 -0
- package/src/db/dbos.ts +17 -0
- package/src/db/identity.ts +61 -0
- package/src/db/index.ts +12 -0
- package/src/db/kms.ts +41 -0
- package/src/db/migrate.ts +71 -0
- package/src/db/wakeup.ts +41 -0
- package/src/db.ts +151 -182
- package/src/deadlines.ts +10 -4
- package/src/deployment-key.ts +11 -3
- package/src/host-derive.ts +26 -9
- package/src/identity-provisioning.ts +85 -57
- package/src/identity-service.ts +19 -19
- package/src/index.ts +3 -0
- package/src/kms-secrets.ts +64 -56
- package/src/ledger.ts +23 -18
- package/src/op.ts +28 -6
- package/src/run-status.ts +7 -6
- package/src/schedules.ts +6 -8
- package/src/schema.ts +11 -8
- package/src/shared.ts +6 -17
- package/src/storage-provisioning.ts +47 -52
- package/src/tokens.ts +43 -33
- package/src/version.ts +5 -5
- package/src/wakeup-service.ts +117 -131
- package/src/worker.ts +38 -21
package/src/connection.ts
CHANGED
|
@@ -16,7 +16,9 @@ import { type Connector, setupOf, VENDOR, defineConnector, defineDriver, type Ca
|
|
|
16
16
|
import { isSecret, type Secret } from "./secret.ts";
|
|
17
17
|
import { SanomaError } from "./errors.ts";
|
|
18
18
|
import { SecretStoreError, type SecretAccess, type SecretLocation } from "./secret-store.ts";
|
|
19
|
+
import { and, eq, getTableColumns, isNull, or, sql } from "drizzle-orm";
|
|
19
20
|
import type { Db } from "./db.ts";
|
|
21
|
+
import { connectionHealth, connections, connectionSetups } from "./db/company.ts";
|
|
20
22
|
|
|
21
23
|
/** Serializable account reference; vendor literal prevents cross-connector selection in typed calls. */
|
|
22
24
|
export interface ConnectionRef<V extends string = string> {
|
|
@@ -255,25 +257,10 @@ export function connectionCatalog(
|
|
|
255
257
|
return { definitions: defined, rules: dynamic, methods };
|
|
256
258
|
}
|
|
257
259
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
owner: string | null;
|
|
263
|
-
rule: string | null;
|
|
264
|
-
account: string;
|
|
265
|
-
scopes: string[];
|
|
266
|
-
revision: string;
|
|
267
|
-
definition: string;
|
|
268
|
-
credential: string | null;
|
|
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;
|
|
276
|
-
};
|
|
260
|
+
/** What a connection's row says of it, as `db/company.ts` declares it. */
|
|
261
|
+
export type ConnectionStatus = "connected" | "disconnected" | "pending" | "uncertain";
|
|
262
|
+
/** A connection's metadata, what `save` writes: its row less what the save sets. */
|
|
263
|
+
type ConnectionRow = Omit<typeof connections.$inferSelect, "location" | "updatedBy" | "updatedAt">;
|
|
277
264
|
const snapshot = (r: ConnectionRow): ConnectionSnapshot => ({
|
|
278
265
|
id: r.id,
|
|
279
266
|
vendor: r.vendor,
|
|
@@ -312,14 +299,21 @@ export class ConnectionRegistry {
|
|
|
312
299
|
|
|
313
300
|
/** Safe card data only. Listing does not read credential values or contact a vendor. */
|
|
314
301
|
async cards(actor: string): Promise<ConnectionCard[]> {
|
|
315
|
-
const rows = (
|
|
316
|
-
await (
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
302
|
+
const rows = await (
|
|
303
|
+
await this.db()
|
|
304
|
+
)
|
|
305
|
+
.select({
|
|
306
|
+
...getTableColumns(connections),
|
|
307
|
+
health: connectionHealth.health,
|
|
308
|
+
checkedAt: connectionHealth.checkedAt,
|
|
309
|
+
externallyRenewed: connectionHealth.externallyRenewed,
|
|
310
|
+
})
|
|
311
|
+
.from(connections)
|
|
312
|
+
.leftJoin(
|
|
313
|
+
connectionHealth,
|
|
314
|
+
and(eq(connections.id, connectionHealth.connectionId), eq(connections.revision, connectionHealth.revision)),
|
|
321
315
|
)
|
|
322
|
-
|
|
316
|
+
.where(or(isNull(connections.owner), eq(connections.owner, actor)));
|
|
323
317
|
const found = new Map(rows.map((r) => [r.id, r]));
|
|
324
318
|
const ids = new Set([...this.catalog.definitions.keys(), ...rows.filter((r) => r.rule).map((r) => r.id)]);
|
|
325
319
|
const cards: ConnectionCard[] = [];
|
|
@@ -352,14 +346,14 @@ export class ConnectionRegistry {
|
|
|
352
346
|
: row.status,
|
|
353
347
|
...(credential && this.secrets && { location: this.secrets.location(credential) }),
|
|
354
348
|
...(row?.location && { boundLocation: row.location }),
|
|
355
|
-
...(row?.
|
|
356
|
-
...(row?.
|
|
349
|
+
...(row?.updatedBy && { updatedBy: row.updatedBy }),
|
|
350
|
+
...(row?.updatedAt && { updatedAt: row.updatedAt.toISOString() }),
|
|
357
351
|
...(row?.health &&
|
|
358
|
-
row.
|
|
352
|
+
row.checkedAt && {
|
|
359
353
|
health: {
|
|
360
354
|
state: row.health,
|
|
361
|
-
checkedAt: row.
|
|
362
|
-
externallyRenewed: row.
|
|
355
|
+
checkedAt: row.checkedAt.toISOString(),
|
|
356
|
+
externallyRenewed: row.externallyRenewed ?? false,
|
|
363
357
|
},
|
|
364
358
|
}),
|
|
365
359
|
});
|
|
@@ -371,7 +365,7 @@ export class ConnectionRegistry {
|
|
|
371
365
|
* stable across preview, approval and retry; account allocation is scoped to its principal. */
|
|
372
366
|
async setup(target: ConnectionTarget, actor: Principal, requestId: string): Promise<ConnectionSetup> {
|
|
373
367
|
let declaration: ConnectionDefinition | ConnectionRule | undefined;
|
|
374
|
-
let row:
|
|
368
|
+
let row: typeof connections.$inferSelect | undefined;
|
|
375
369
|
let id: string;
|
|
376
370
|
let credential: string | undefined;
|
|
377
371
|
if ("rule" in target) {
|
|
@@ -453,30 +447,39 @@ export class ConnectionRegistry {
|
|
|
453
447
|
async uncertain(ref: ConnectionRef, revision: string, actor: string): Promise<void> {
|
|
454
448
|
await (
|
|
455
449
|
await this.db()
|
|
456
|
-
)
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
450
|
+
)
|
|
451
|
+
.update(connections)
|
|
452
|
+
.set({ status: "uncertain", updatedBy: actor })
|
|
453
|
+
.where(
|
|
454
|
+
and(eq(connections.id, ref.$connection), eq(connections.revision, revision), eq(connections.status, "pending")),
|
|
455
|
+
);
|
|
460
456
|
}
|
|
461
457
|
|
|
462
458
|
async storedVersion(id: string, revision: string): Promise<string | undefined> {
|
|
463
|
-
|
|
464
|
-
await (
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
).rows[0]?.credential_version;
|
|
459
|
+
const [row] = await (
|
|
460
|
+
await this.db()
|
|
461
|
+
)
|
|
462
|
+
.select({ credentialVersion: connectionSetups.credentialVersion })
|
|
463
|
+
.from(connectionSetups)
|
|
464
|
+
.where(and(eq(connectionSetups.connectionId, id), eq(connectionSetups.revision, revision)));
|
|
465
|
+
return row?.credentialVersion;
|
|
471
466
|
}
|
|
472
467
|
|
|
473
468
|
async recordStoredVersion(id: string, revision: string, version: string): Promise<void> {
|
|
474
|
-
await (
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
469
|
+
const db = await this.db();
|
|
470
|
+
await db
|
|
471
|
+
.insert(connectionSetups)
|
|
472
|
+
.select(
|
|
473
|
+
db
|
|
474
|
+
.select({
|
|
475
|
+
connectionId: connections.id,
|
|
476
|
+
revision: connections.revision,
|
|
477
|
+
credentialVersion: sql<string>`${version}`.as("credential_version"),
|
|
478
|
+
})
|
|
479
|
+
.from(connections)
|
|
480
|
+
.where(and(eq(connections.id, id), eq(connections.revision, revision), eq(connections.status, "pending"))),
|
|
481
|
+
)
|
|
482
|
+
.onConflictDoNothing();
|
|
480
483
|
if ((await this.storedVersion(id, revision)) !== version) throw new SecretStoreError("conflict");
|
|
481
484
|
}
|
|
482
485
|
|
|
@@ -487,28 +490,45 @@ export class ConnectionRegistry {
|
|
|
487
490
|
version?: string,
|
|
488
491
|
external = false,
|
|
489
492
|
): Promise<ConnectionHealth> {
|
|
490
|
-
const
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
[id, revision],
|
|
496
|
-
)
|
|
497
|
-
).rows[0];
|
|
493
|
+
const db = await this.db();
|
|
494
|
+
const [previous] = await db
|
|
495
|
+
.select({ credentialVersion: connectionHealth.credentialVersion })
|
|
496
|
+
.from(connectionHealth)
|
|
497
|
+
.where(and(eq(connectionHealth.connectionId, id), eq(connectionHealth.revision, revision)));
|
|
498
498
|
const renewed =
|
|
499
499
|
external &&
|
|
500
|
-
previous?.
|
|
500
|
+
previous?.credentialVersion != null &&
|
|
501
501
|
version !== undefined &&
|
|
502
|
-
previous.
|
|
503
|
-
const
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
502
|
+
previous.credentialVersion !== version;
|
|
503
|
+
const credentialVersion = version ?? previous?.credentialVersion ?? null;
|
|
504
|
+
const [checked] = await db
|
|
505
|
+
.insert(connectionHealth)
|
|
506
|
+
.select(
|
|
507
|
+
db
|
|
508
|
+
.select({
|
|
509
|
+
connectionId: connections.id,
|
|
510
|
+
revision: connections.revision,
|
|
511
|
+
health: sql<string>`${state}`.as("health"),
|
|
512
|
+
credentialVersion: sql<string | null>`${credentialVersion}`.as("credential_version"),
|
|
513
|
+
externallyRenewed: sql<boolean>`${renewed}`.as("externally_renewed"),
|
|
514
|
+
checkedAt: sql<Date>`now()`.as("checked_at"),
|
|
515
|
+
})
|
|
516
|
+
.from(connections)
|
|
517
|
+
.where(and(eq(connections.id, id), eq(connections.revision, revision))),
|
|
518
|
+
)
|
|
519
|
+
.onConflictDoUpdate({
|
|
520
|
+
target: connectionHealth.connectionId,
|
|
521
|
+
set: {
|
|
522
|
+
revision,
|
|
523
|
+
health: state,
|
|
524
|
+
credentialVersion,
|
|
525
|
+
externallyRenewed: renewed,
|
|
526
|
+
checkedAt: sql`now()`,
|
|
527
|
+
},
|
|
528
|
+
})
|
|
529
|
+
.returning({ checkedAt: connectionHealth.checkedAt });
|
|
510
530
|
if (!checked) refuse("The connection changed during the test; reload before testing it again");
|
|
511
|
-
return { state, checkedAt: checked.
|
|
531
|
+
return { state, checkedAt: checked.checkedAt.toISOString(), externallyRenewed: renewed };
|
|
512
532
|
}
|
|
513
533
|
|
|
514
534
|
async test(ref: ConnectionRef, actor: Principal): Promise<ConnectionHealth> {
|
|
@@ -578,13 +598,14 @@ export class ConnectionRegistry {
|
|
|
578
598
|
if (JSON.stringify(identity.scopes) !== JSON.stringify([...new Set(row.scopes)].toSorted()))
|
|
579
599
|
throw new AuthenticationError("scope_changed");
|
|
580
600
|
const next = randomUUID();
|
|
581
|
-
const
|
|
601
|
+
const [activated] = await (
|
|
582
602
|
await this.db()
|
|
583
|
-
)
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
603
|
+
)
|
|
604
|
+
.update(connections)
|
|
605
|
+
.set({ status: "connected", revision: next, updatedBy: actor.id })
|
|
606
|
+
.where(and(eq(connections.id, row.id), eq(connections.revision, revision)))
|
|
607
|
+
.returning({ id: connections.id });
|
|
608
|
+
if (!activated) refuse("The connection changed; review it again before activating");
|
|
588
609
|
await this.health(row.id, next, "connected", bundle?.version);
|
|
589
610
|
return snapshot({ ...row, revision: next, status: "connected" });
|
|
590
611
|
}
|
|
@@ -617,8 +638,9 @@ export class ConnectionRegistry {
|
|
|
617
638
|
checkAuthenticationStore(method, caps, location.management);
|
|
618
639
|
}
|
|
619
640
|
|
|
620
|
-
private async row(id: string)
|
|
621
|
-
|
|
641
|
+
private async row(id: string) {
|
|
642
|
+
const [row] = await (await this.db()).select().from(connections).where(eq(connections.id, id));
|
|
643
|
+
return row;
|
|
622
644
|
}
|
|
623
645
|
|
|
624
646
|
async select(vendor: string, ref: ConnectionRef | undefined, actor: string): Promise<ConnectionSnapshot | undefined> {
|
|
@@ -772,38 +794,46 @@ export class ConnectionRegistry {
|
|
|
772
794
|
if (row.rule && row.owner !== actor)
|
|
773
795
|
throw new SanomaError("policy_denied", "The connection belongs to another principal");
|
|
774
796
|
const revision = randomUUID();
|
|
775
|
-
const changed = await (
|
|
797
|
+
const [changed] = await (
|
|
776
798
|
await this.db()
|
|
777
|
-
)
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
799
|
+
)
|
|
800
|
+
.update(connections)
|
|
801
|
+
.set({ status: "disconnected", revision, updatedBy: actor })
|
|
802
|
+
.where(and(eq(connections.id, row.id), eq(connections.revision, expectedRevision)))
|
|
803
|
+
.returning({ id: connections.id });
|
|
804
|
+
if (!changed) refuse("The connection changed; reload before disconnecting it");
|
|
782
805
|
return { id: row.id, revision, status: "disconnected" };
|
|
783
806
|
}
|
|
784
807
|
|
|
785
808
|
private async save(row: ConnectionRow, expected: string | null, actor: string): Promise<void> {
|
|
786
|
-
const
|
|
787
|
-
|
|
788
|
-
row.vendor,
|
|
789
|
-
row.method,
|
|
790
|
-
row.owner,
|
|
791
|
-
row.rule,
|
|
792
|
-
row.account,
|
|
793
|
-
|
|
794
|
-
row.revision,
|
|
795
|
-
row.definition,
|
|
796
|
-
row.credential,
|
|
797
|
-
actor,
|
|
798
|
-
row.status,
|
|
799
|
-
row.credential && this.secrets ?
|
|
800
|
-
|
|
801
|
-
const
|
|
809
|
+
const db = await this.db();
|
|
810
|
+
const values = {
|
|
811
|
+
vendor: row.vendor,
|
|
812
|
+
method: row.method,
|
|
813
|
+
owner: row.owner,
|
|
814
|
+
rule: row.rule,
|
|
815
|
+
account: row.account,
|
|
816
|
+
scopes: row.scopes,
|
|
817
|
+
revision: row.revision,
|
|
818
|
+
definition: row.definition,
|
|
819
|
+
credential: row.credential,
|
|
820
|
+
updatedBy: actor,
|
|
821
|
+
status: row.status,
|
|
822
|
+
location: row.credential && this.secrets ? this.secrets.location(row.credential) : null,
|
|
823
|
+
};
|
|
824
|
+
const [saved] =
|
|
802
825
|
expected === null
|
|
803
|
-
?
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
826
|
+
? await db
|
|
827
|
+
.insert(connections)
|
|
828
|
+
.values({ id: row.id, ...values })
|
|
829
|
+
.onConflictDoNothing()
|
|
830
|
+
.returning({ id: connections.id })
|
|
831
|
+
: await db
|
|
832
|
+
.update(connections)
|
|
833
|
+
.set(values)
|
|
834
|
+
.where(and(eq(connections.id, row.id), eq(connections.revision, expected)))
|
|
835
|
+
.returning({ id: connections.id });
|
|
836
|
+
if (!saved) refuse("The connection changed; reload before binding it");
|
|
807
837
|
}
|
|
808
838
|
}
|
|
809
839
|
|
package/src/credentials.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { and, eq } from "drizzle-orm";
|
|
3
|
+
import type { Db } from "./db.ts";
|
|
4
|
+
import { credentials } from "./db/company.ts";
|
|
3
5
|
import {
|
|
4
6
|
SecretAccess,
|
|
5
7
|
SecretStoreError,
|
|
@@ -40,13 +42,21 @@ export function decrypt(identity: string, stored: string): string {
|
|
|
40
42
|
}
|
|
41
43
|
}
|
|
42
44
|
|
|
43
|
-
|
|
45
|
+
const returned = {
|
|
46
|
+
value: credentials.value,
|
|
47
|
+
version: credentials.version,
|
|
48
|
+
setBy: credentials.setBy,
|
|
49
|
+
setAt: credentials.setAt,
|
|
50
|
+
};
|
|
51
|
+
type Row = Pick<typeof credentials.$inferSelect, keyof typeof returned>;
|
|
52
|
+
const at = ({ namespace, path }: SecretAddress) =>
|
|
53
|
+
and(eq(credentials.namespace, namespace), eq(credentials.name, path));
|
|
44
54
|
const identity = ({ namespace, path }: SecretAddress) => JSON.stringify([namespace, path]);
|
|
45
55
|
const decoded = (address: SecretAddress, row: Row): SecretValue => ({
|
|
46
56
|
value: decrypt(identity(address), row.value),
|
|
47
57
|
version: row.version,
|
|
48
|
-
updatedBy: row.
|
|
49
|
-
updatedAt: row.
|
|
58
|
+
updatedBy: row.setBy,
|
|
59
|
+
updatedAt: row.setAt.toISOString(),
|
|
50
60
|
});
|
|
51
61
|
|
|
52
62
|
/** One company's database connection supplies authority; namespace separates deployments within it. */
|
|
@@ -63,46 +73,40 @@ export function postgresSecretStore(db: () => Promise<Db>): SecretStore {
|
|
|
63
73
|
async validate(namespace) {
|
|
64
74
|
if (!namespace) throw new SecretStoreError("invalid_configuration");
|
|
65
75
|
deploymentKey(SECRETS_KEY);
|
|
66
|
-
await (await db()).
|
|
76
|
+
await (await db()).select({ namespace: credentials.namespace }).from(credentials).limit(0);
|
|
67
77
|
},
|
|
68
78
|
async read(address) {
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
).query<Row>(`SELECT value, version, set_by, set_at FROM ${TABLE} WHERE namespace = $1 AND name = $2`, [
|
|
72
|
-
address.namespace,
|
|
73
|
-
address.path,
|
|
74
|
-
]);
|
|
75
|
-
return rows[0] && decoded(address, rows[0]);
|
|
79
|
+
const [row] = await (await db()).select(returned).from(credentials).where(at(address));
|
|
80
|
+
return row && decoded(address, row);
|
|
76
81
|
},
|
|
77
82
|
async write(address, value, { expectedVersion, by }) {
|
|
78
83
|
if (Buffer.byteLength(value) > 65_536) throw new SecretStoreError("too_large");
|
|
79
84
|
const version = randomUUID();
|
|
80
85
|
const sealed = encrypt(identity(address), value);
|
|
81
|
-
const
|
|
86
|
+
const database = await db();
|
|
87
|
+
const [row] =
|
|
82
88
|
expectedVersion === null
|
|
83
|
-
?
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
return decoded(address, rows[0]);
|
|
89
|
+
? await database
|
|
90
|
+
.insert(credentials)
|
|
91
|
+
.values({ namespace: address.namespace, name: address.path, value: sealed, version, setBy: by })
|
|
92
|
+
.onConflictDoNothing()
|
|
93
|
+
.returning(returned)
|
|
94
|
+
: await database
|
|
95
|
+
.update(credentials)
|
|
96
|
+
.set({ value: sealed, version, setBy: by })
|
|
97
|
+
.where(and(at(address), eq(credentials.version, expectedVersion)))
|
|
98
|
+
.returning(returned);
|
|
99
|
+
if (!row) throw new SecretStoreError("conflict");
|
|
100
|
+
return decoded(address, row);
|
|
96
101
|
},
|
|
97
102
|
async delete(address, { expectedVersion }) {
|
|
98
|
-
const
|
|
103
|
+
const deleted = await (
|
|
99
104
|
await db()
|
|
100
|
-
)
|
|
101
|
-
|
|
102
|
-
address.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if (!rowCount) throw new SecretStoreError("conflict");
|
|
105
|
+
)
|
|
106
|
+
.delete(credentials)
|
|
107
|
+
.where(and(at(address), eq(credentials.version, expectedVersion)))
|
|
108
|
+
.returning({ name: credentials.name });
|
|
109
|
+
if (!deleted.length) throw new SecretStoreError("conflict");
|
|
106
110
|
},
|
|
107
111
|
};
|
|
108
112
|
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import type { UIMessage } from "ai";
|
|
2
|
+
import { sql } from "drizzle-orm";
|
|
3
|
+
import {
|
|
4
|
+
bigserial,
|
|
5
|
+
boolean,
|
|
6
|
+
check,
|
|
7
|
+
index,
|
|
8
|
+
integer,
|
|
9
|
+
jsonb,
|
|
10
|
+
pgTable,
|
|
11
|
+
primaryKey,
|
|
12
|
+
text,
|
|
13
|
+
timestamp,
|
|
14
|
+
} from "drizzle-orm/pg-core";
|
|
15
|
+
import type { ConnectionHealth, ConnectionStatus } from "../connection.ts";
|
|
16
|
+
import type { LedgerRecord } from "../ledger.ts";
|
|
17
|
+
import type { SecretLocation } from "../secret-store.ts";
|
|
18
|
+
|
|
19
|
+
// The runtime's tables in a company's database, beside DBOS's: what `prepareDatabase` makes and
|
|
20
|
+
// every store reads. Column names are the property names in snake_case (`casing` in `db.ts` and
|
|
21
|
+
// in `drizzle/company.config.ts`). A change here is a new migration: `pnpm --filter
|
|
22
|
+
// @sanoma-ai/workflows generate` writes it under `drizzle/company/`, and CI refuses a schema
|
|
23
|
+
// whose migration was not generated.
|
|
24
|
+
|
|
25
|
+
const when = () => timestamp({ withTimezone: true });
|
|
26
|
+
|
|
27
|
+
/** The credentials set from the app, each sealed under its namespace and name. */
|
|
28
|
+
export const credentials = pgTable(
|
|
29
|
+
"sanoma_credentials",
|
|
30
|
+
{
|
|
31
|
+
namespace: text().notNull(),
|
|
32
|
+
name: text().notNull(),
|
|
33
|
+
value: text().notNull(),
|
|
34
|
+
version: text().notNull(),
|
|
35
|
+
setBy: text().notNull(),
|
|
36
|
+
setAt: when()
|
|
37
|
+
.notNull()
|
|
38
|
+
.defaultNow()
|
|
39
|
+
.$onUpdate(() => sql`now()`),
|
|
40
|
+
},
|
|
41
|
+
(t) => [primaryKey({ columns: [t.namespace, t.name] })],
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The Postgres ledger's records. `n` is the write order, `id` the record's, which the first write
|
|
46
|
+
* of takes, and `seq` the record's place in its run: a run reads in `seq` order, then write order.
|
|
47
|
+
*/
|
|
48
|
+
export const ledger = pgTable(
|
|
49
|
+
"sanoma_ledger",
|
|
50
|
+
{
|
|
51
|
+
n: bigserial({ mode: "number" }).primaryKey(),
|
|
52
|
+
id: text().notNull().unique(),
|
|
53
|
+
runId: text().notNull(),
|
|
54
|
+
seq: integer().notNull(),
|
|
55
|
+
record: jsonb().$type<LedgerRecord>().notNull(),
|
|
56
|
+
},
|
|
57
|
+
(t) => [index("sanoma_ledger_run_seq").on(t.runId, t.seq, t.n)],
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
/** The collections' records (`collection.ts`): one row per collection and key, the record as JSON. */
|
|
61
|
+
export const data = pgTable(
|
|
62
|
+
"sanoma_data",
|
|
63
|
+
{
|
|
64
|
+
collection: text().notNull(),
|
|
65
|
+
key: text().notNull(),
|
|
66
|
+
record: jsonb().$type<Record<string, unknown>>().notNull(),
|
|
67
|
+
updatedAt: when().notNull().defaultNow(),
|
|
68
|
+
updatedBy: text().notNull(),
|
|
69
|
+
},
|
|
70
|
+
(t) => [primaryKey({ columns: [t.collection, t.key] })],
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The instance's side of its wakeup: one row, made with the table (`0001_wakeup_row.sql`). `at`
|
|
75
|
+
* is the deadline last registered, `stopped_at` when the worker last stopped itself (the
|
|
76
|
+
* schedules backfill from it), `last_error` why the last registration failed.
|
|
77
|
+
*/
|
|
78
|
+
export const wakeup = pgTable(
|
|
79
|
+
"sanoma_wakeup",
|
|
80
|
+
{
|
|
81
|
+
id: integer().primaryKey(),
|
|
82
|
+
at: when(),
|
|
83
|
+
stoppedAt: when(),
|
|
84
|
+
lastError: text(),
|
|
85
|
+
},
|
|
86
|
+
(t) => [check("sanoma_wakeup_one_row", sql`${t.id} = 1`)],
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
/** The operator agent's chats (`chats.ts`): who each is for, its engine and its draft. */
|
|
90
|
+
export const chats = pgTable(
|
|
91
|
+
"sanoma_chats",
|
|
92
|
+
{
|
|
93
|
+
id: text().primaryKey(),
|
|
94
|
+
person: text().notNull(),
|
|
95
|
+
engine: text().notNull(),
|
|
96
|
+
draftId: text(),
|
|
97
|
+
title: text().notNull(),
|
|
98
|
+
createdAt: when().notNull().defaultNow(),
|
|
99
|
+
},
|
|
100
|
+
(t) => [index("sanoma_chats_person").on(t.person, t.createdAt)],
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
/** Each chat's messages, the AI SDK's UI messages as JSON, in `seq` order. */
|
|
104
|
+
export const chatMessages = pgTable(
|
|
105
|
+
"sanoma_chat_messages",
|
|
106
|
+
{
|
|
107
|
+
chatId: text()
|
|
108
|
+
.notNull()
|
|
109
|
+
.references(() => chats.id, { onDelete: "cascade" }),
|
|
110
|
+
id: text().notNull(),
|
|
111
|
+
seq: integer().notNull(),
|
|
112
|
+
message: jsonb().$type<UIMessage>().notNull(),
|
|
113
|
+
},
|
|
114
|
+
(t) => [primaryKey({ columns: [t.chatId, t.id] })],
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
/** The api tokens (`tokens.ts`): each one's SHA-256, never the token, whom it acts for, its groups, and when it was revoked. */
|
|
118
|
+
export const apiTokens = pgTable("sanoma_api_tokens", {
|
|
119
|
+
id: text().primaryKey(),
|
|
120
|
+
hash: text().notNull().unique(),
|
|
121
|
+
subject: text().notNull(),
|
|
122
|
+
groups: jsonb().$type<string[]>().notNull(),
|
|
123
|
+
createdAt: when().notNull().defaultNow(),
|
|
124
|
+
revokedAt: when(),
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
/** The nonces of sealed invite requests spent (`spendInviteRequest`), each until it expires. */
|
|
128
|
+
export const inviteRequests = pgTable("sanoma_invite_requests", {
|
|
129
|
+
nonce: text().primaryKey(),
|
|
130
|
+
expiresAt: when().notNull(),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
/** The connected accounts (`connection.ts`): metadata only, never an authentication value. */
|
|
134
|
+
export const connections = pgTable("sanoma_connections", {
|
|
135
|
+
id: text().primaryKey(),
|
|
136
|
+
vendor: text().notNull(),
|
|
137
|
+
method: text().notNull(),
|
|
138
|
+
owner: text(),
|
|
139
|
+
rule: text(),
|
|
140
|
+
account: text().notNull(),
|
|
141
|
+
scopes: jsonb().$type<string[]>().notNull(),
|
|
142
|
+
revision: text().notNull(),
|
|
143
|
+
definition: text().notNull(),
|
|
144
|
+
credential: text(),
|
|
145
|
+
location: jsonb().$type<SecretLocation>(),
|
|
146
|
+
status: text().$type<ConnectionStatus>().notNull().default("connected"),
|
|
147
|
+
updatedBy: text().notNull(),
|
|
148
|
+
updatedAt: when()
|
|
149
|
+
.notNull()
|
|
150
|
+
.defaultNow()
|
|
151
|
+
.$onUpdate(() => sql`now()`),
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
/** Each connection's last health check. */
|
|
155
|
+
export const connectionHealth = pgTable("sanoma_connection_health", {
|
|
156
|
+
connectionId: text().primaryKey(),
|
|
157
|
+
revision: text().notNull(),
|
|
158
|
+
health: text().$type<ConnectionHealth["state"]>().notNull(),
|
|
159
|
+
credentialVersion: text(),
|
|
160
|
+
externallyRenewed: boolean().notNull(),
|
|
161
|
+
checkedAt: when().notNull().defaultNow(),
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
/** The receipts of a connection's setup, one per revision. */
|
|
165
|
+
export const connectionSetups = pgTable(
|
|
166
|
+
"sanoma_connection_setups",
|
|
167
|
+
{
|
|
168
|
+
connectionId: text().notNull(),
|
|
169
|
+
revision: text().notNull(),
|
|
170
|
+
credentialVersion: text().notNull(),
|
|
171
|
+
},
|
|
172
|
+
(t) => [primaryKey({ columns: [t.connectionId, t.revision] })],
|
|
173
|
+
);
|
package/src/db/dbos.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { pgSchema, text } from "drizzle-orm/pg-core";
|
|
2
|
+
|
|
3
|
+
// What the runtime reads of DBOS's own tables (dbos-sdk 5.2.11), in its schema `dbos`: the
|
|
4
|
+
// columns named here only. DBOS makes and migrates these tables (`DBOS.migrate`); no migration is
|
|
5
|
+
// generated from this model, and the runtime's one write to them is `stampVersion` (`db.ts`).
|
|
6
|
+
|
|
7
|
+
export const dbos = pgSchema("dbos");
|
|
8
|
+
|
|
9
|
+
/** Each run's status row. */
|
|
10
|
+
export const workflowStatus = dbos.table("workflow_status", {
|
|
11
|
+
workflowUuid: text().primaryKey(),
|
|
12
|
+
status: text().notNull(),
|
|
13
|
+
name: text(),
|
|
14
|
+
applicationName: text(),
|
|
15
|
+
applicationVersion: text(),
|
|
16
|
+
queueName: text(),
|
|
17
|
+
});
|