@sanoma-ai/workflows 0.11.12 → 0.11.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/CLOUD-IDENTITY.md +12 -4
  2. package/KMS-SECRETS.md +3 -1
  3. package/README.md +4 -2
  4. package/dist/bundled.d.ts +2 -2
  5. package/dist/chats.d.ts +3 -3
  6. package/dist/chats.js +34 -20
  7. package/dist/chats.js.map +1 -1
  8. package/dist/client.d.ts +17 -4
  9. package/dist/client.js +74 -63
  10. package/dist/client.js.map +1 -1
  11. package/dist/cloud-identity.js +14 -12
  12. package/dist/cloud-identity.js.map +1 -1
  13. package/dist/collection.d.ts +2 -2
  14. package/dist/collection.js +53 -42
  15. package/dist/collection.js.map +1 -1
  16. package/dist/config.d.ts +11 -6
  17. package/dist/config.js +15 -13
  18. package/dist/config.js.map +1 -1
  19. package/dist/connection.d.ts +2 -0
  20. package/dist/connection.js +112 -40
  21. package/dist/connection.js.map +1 -1
  22. package/dist/credentials.d.ts +1 -1
  23. package/dist/credentials.js +33 -29
  24. package/dist/credentials.js.map +1 -1
  25. package/dist/db/company.d.ts +1152 -0
  26. package/dist/db/company.js +118 -0
  27. package/dist/db/company.js.map +1 -0
  28. package/dist/db/dbos.d.ts +111 -0
  29. package/dist/db/dbos.js +15 -0
  30. package/dist/db/dbos.js.map +1 -0
  31. package/dist/db/identity.d.ts +367 -0
  32. package/dist/db/identity.js +48 -0
  33. package/dist/db/identity.js.map +1 -0
  34. package/dist/db/index.d.ts +6 -0
  35. package/dist/db/index.js +10 -0
  36. package/dist/db/index.js.map +1 -0
  37. package/dist/db/kms.d.ts +224 -0
  38. package/dist/db/kms.js +29 -0
  39. package/dist/db/kms.js.map +1 -0
  40. package/dist/db/migrate.d.ts +63 -0
  41. package/dist/db/migrate.js +41 -0
  42. package/dist/db/migrate.js.map +1 -0
  43. package/dist/db/wakeup.d.ts +271 -0
  44. package/dist/db/wakeup.js +34 -0
  45. package/dist/db/wakeup.js.map +1 -0
  46. package/dist/db.d.ts +42 -39
  47. package/dist/db.js +129 -169
  48. package/dist/db.js.map +1 -1
  49. package/dist/deadlines.js +8 -4
  50. package/dist/deadlines.js.map +1 -1
  51. package/dist/deployment-key.d.ts +4 -1
  52. package/dist/deployment-key.js +10 -3
  53. package/dist/deployment-key.js.map +1 -1
  54. package/dist/host-derive.d.ts +12 -3
  55. package/dist/host-derive.js +21 -8
  56. package/dist/host-derive.js.map +1 -1
  57. package/dist/identity-provisioning.d.ts +2 -2
  58. package/dist/identity-provisioning.js +75 -42
  59. package/dist/identity-provisioning.js.map +1 -1
  60. package/dist/identity-service.js +18 -15
  61. package/dist/identity-service.js.map +1 -1
  62. package/dist/index.d.ts +2 -2
  63. package/dist/index.js +1 -1
  64. package/dist/index.js.map +1 -1
  65. package/dist/kms-secrets.d.ts +9 -0
  66. package/dist/kms-secrets.js +59 -27
  67. package/dist/kms-secrets.js.map +1 -1
  68. package/dist/ledger.d.ts +1 -1
  69. package/dist/ledger.js +22 -14
  70. package/dist/ledger.js.map +1 -1
  71. package/dist/op.d.ts +27 -6
  72. package/dist/op.js.map +1 -1
  73. package/dist/run-status.js +7 -3
  74. package/dist/run-status.js.map +1 -1
  75. package/dist/schedules.d.ts +1 -1
  76. package/dist/schedules.js +5 -7
  77. package/dist/schedules.js.map +1 -1
  78. package/dist/schema.d.ts +6 -5
  79. package/dist/schema.js +11 -7
  80. package/dist/schema.js.map +1 -1
  81. package/dist/shared.d.ts +6 -23
  82. package/dist/shared.js +6 -5
  83. package/dist/shared.js.map +1 -1
  84. package/dist/storage-provisioning.d.ts +2 -0
  85. package/dist/storage-provisioning.js +51 -50
  86. package/dist/storage-provisioning.js.map +1 -1
  87. package/dist/tokens.d.ts +2 -2
  88. package/dist/tokens.js +31 -20
  89. package/dist/tokens.js.map +1 -1
  90. package/dist/version.d.ts +4 -4
  91. package/dist/version.js +5 -5
  92. package/dist/version.js.map +1 -1
  93. package/dist/wakeup-service.d.ts +0 -4
  94. package/dist/wakeup-service.js +102 -93
  95. package/dist/wakeup-service.js.map +1 -1
  96. package/dist/worker.d.ts +7 -0
  97. package/dist/worker.js +26 -21
  98. package/dist/worker.js.map +1 -1
  99. package/drizzle/company/0000_tables.sql +104 -0
  100. package/drizzle/company/0001_wakeup_row.sql +2 -0
  101. package/drizzle/company/meta/0000_snapshot.json +652 -0
  102. package/drizzle/company/meta/0001_snapshot.json +652 -0
  103. package/drizzle/company/meta/_journal.json +20 -0
  104. package/drizzle/wakeup/0000_tables.sql +26 -0
  105. package/drizzle/wakeup/meta/0000_snapshot.json +200 -0
  106. package/drizzle/wakeup/meta/_journal.json +13 -0
  107. package/oxlint.json +2 -1
  108. package/package.json +11 -2
  109. package/src/bundled.ts +2 -2
  110. package/src/chats.ts +41 -42
  111. package/src/client.ts +81 -64
  112. package/src/cloud-identity.ts +23 -24
  113. package/src/collection.ts +58 -50
  114. package/src/config.ts +29 -19
  115. package/src/connection.ts +132 -102
  116. package/src/credentials.ts +37 -33
  117. package/src/db/company.ts +173 -0
  118. package/src/db/dbos.ts +17 -0
  119. package/src/db/identity.ts +61 -0
  120. package/src/db/index.ts +10 -0
  121. package/src/db/kms.ts +41 -0
  122. package/src/db/migrate.ts +70 -0
  123. package/src/db/wakeup.ts +41 -0
  124. package/src/db.ts +151 -182
  125. package/src/deadlines.ts +10 -4
  126. package/src/deployment-key.ts +11 -3
  127. package/src/host-derive.ts +26 -9
  128. package/src/identity-provisioning.ts +85 -57
  129. package/src/identity-service.ts +19 -19
  130. package/src/index.ts +3 -0
  131. package/src/kms-secrets.ts +64 -56
  132. package/src/ledger.ts +23 -18
  133. package/src/op.ts +28 -6
  134. package/src/run-status.ts +7 -6
  135. package/src/schedules.ts +6 -8
  136. package/src/schema.ts +11 -8
  137. package/src/shared.ts +6 -17
  138. package/src/storage-provisioning.ts +47 -52
  139. package/src/tokens.ts +43 -33
  140. package/src/version.ts +5 -5
  141. package/src/wakeup-service.ts +117 -131
  142. package/src/worker.ts +38 -21
package/src/run-status.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { WorkflowStatusString } from "@dbos-inc/dbos-sdk";
2
+ import { and, eq } from "drizzle-orm";
2
3
  import type { Db } from "./db.ts";
4
+ import { workflowStatus } from "./db/dbos.ts";
3
5
 
4
6
  /** Metadata only: never read a run's inputs, outputs, error or authentication data. */
5
7
  export type RunObservation = {
@@ -24,15 +26,14 @@ export const inspectRun =
24
26
  (database: () => Promise<Db>, appName: string): InspectRun =>
25
27
  async (runId) => {
26
28
  const db = await database();
27
- const { rows } = await db.query<{ name: string; status: string }>(
28
- "SELECT name,status FROM dbos.workflow_status WHERE workflow_uuid=$1 AND application_name=$2",
29
- [runId, appName],
30
- );
31
- const row = rows[0];
29
+ const [row] = await db
30
+ .select({ name: workflowStatus.name, status: workflowStatus.status })
31
+ .from(workflowStatus)
32
+ .where(and(eq(workflowStatus.workflowUuid, runId), eq(workflowStatus.applicationName, appName)));
32
33
  if (!row) return { runId, status: "missing" };
33
34
  return {
34
35
  runId,
35
- workflow: row.name,
36
+ ...(row.name !== null && { workflow: row.name }),
36
37
  status: Object.hasOwn(statuses, row.status) ? statuses[row.status as WorkflowStatusString] : "active",
37
38
  };
38
39
  };
package/src/schedules.ts CHANGED
@@ -2,7 +2,9 @@ import { DBOS, type ScheduledWorkflowFn, type WorkflowSchedule } from "@dbos-inc
2
2
  import { createRequire } from "node:module";
3
3
  import { dirname, join } from "node:path";
4
4
  import type { ResolvedConfig } from "./config.ts";
5
- import { type Db, WAKEUP_TABLE } from "./db.ts";
5
+ import { eq } from "drizzle-orm";
6
+ import type { Db } from "./db.ts";
7
+ import { wakeup } from "./db/company.ts";
6
8
  import type { Principal, WorkflowDefinition } from "./define.ts";
7
9
  import { readPeople, admit } from "./resources.ts";
8
10
  import type { WorkerState } from "./run.ts";
@@ -77,7 +79,9 @@ export async function applyScheduled(
77
79
  await Promise.all([...before.keys()].filter((name) => !names.has(name)).map((name) => DBOS.deleteSchedule(name)));
78
80
  const unfired = declared.filter((d) => before.get(d.schedule.scheduleName)?.lastFiredAt === null);
79
81
  if (!unfired.length) return;
80
- const stoppedAt = await lastStopped(await db());
82
+ // When the instance's worker last stopped itself, from the wakeup row; never, and nothing was missed.
83
+ const [row] = await (await db()).select({ stoppedAt: wakeup.stoppedAt }).from(wakeup).where(eq(wakeup.id, 1));
84
+ const stoppedAt = row?.stoppedAt;
81
85
  if (!stoppedAt) return;
82
86
  for (const { schedule, since } of unfired) {
83
87
  const from = stoppedAt > since ? stoppedAt : since;
@@ -104,12 +108,6 @@ function sinceOf(schedule: WorkflowSchedule | undefined): Date | undefined {
104
108
  return at && !Number.isNaN(at.getTime()) ? at : undefined;
105
109
  }
106
110
 
107
- /** When the instance's worker last stopped, from the wakeup's row; undefined while it never has. */
108
- async function lastStopped(db: Db): Promise<Date | undefined> {
109
- const { rows } = await db.query<{ stopped_at: Date | null }>(`SELECT stopped_at FROM ${WAKEUP_TABLE} WHERE id = 1`);
110
- return rows[0]?.stopped_at ?? undefined;
111
- }
112
-
113
111
  /** Who starts a scheduled run, as `scheduledStarter` admitted them, or why they were not. */
114
112
  export interface ScheduledStarter {
115
113
  startedBy: Principal;
package/src/schema.ts CHANGED
@@ -22,20 +22,23 @@ import { CHANGE_MERGE, DATA_VENDOR, EFFECTS, groupNamesOf, type PeopleResource,
22
22
  /** Cedar's bindings: its builds (`/nodejs`, `/web`) export the same functions. */
23
23
  export type CedarWasm = typeof import("@cedar-policy/cedar-wasm/nodejs");
24
24
  let wasm: CedarWasm | undefined;
25
+ /** How Cedar is loaded on first use: its Node build, unless `useCedarWasm` gave another. */
26
+ let load = () => createRequire(import.meta.url)("@cedar-policy/cedar-wasm/nodejs") as CedarWasm;
25
27
 
26
28
  /**
27
- * `@cedar-policy/cedar-wasm`'s Node build, loaded (synchronously, once) on first use, so a process
28
- * that never decides with Cedar never loads it; or the build `useCedarWasm` gave.
29
+ * Cedar's bindings, loaded (synchronously, once) on first use, so a process that never decides
30
+ * with Cedar never loads it: `@cedar-policy/cedar-wasm`'s Node build, or what `useCedarWasm`'s loader gives.
29
31
  */
30
- export const cedarWasm = (): CedarWasm =>
31
- (wasm ??= createRequire(import.meta.url)("@cedar-policy/cedar-wasm/nodejs") as CedarWasm);
32
+ export const cedarWasm = (): CedarWasm => (wasm ??= load());
32
33
 
33
34
  /**
34
- * From now on, Cedar is `bindings`: its web build, initialised, where the Node build cannot load
35
- * (the Cloudflare host's Worker, which bundles the runtime).
35
+ * From now on, Cedar is what `loader` gives on first use: its web build, initialised, where the
36
+ * Node build cannot load (workerd, which bundles the runtime), and instantiated only once a
37
+ * request decides with it.
36
38
  */
37
- export function useCedarWasm(bindings: CedarWasm): void {
38
- wasm = bindings;
39
+ export function useCedarWasm(loader: () => CedarWasm): void {
40
+ wasm = undefined;
41
+ load = loader;
39
42
  }
40
43
 
41
44
  /** The schema as Cedar's JSON format has it. */
package/src/shared.ts CHANGED
@@ -142,24 +142,13 @@ export const domainsText = (domains: Readonly<Record<string, string>>): string =
142
142
  .join(",");
143
143
 
144
144
  /**
145
- * Where a hosted container answers, with its instance token (`SANOMA_WAKEUP_TOKEN`), the bundle
146
- * of its public sites that a host's Worker serves: the Cloudflare host downloads it at a deploy.
145
+ * Where a hosted worker's port answers, with its instance token (`SANOMA_WAKEUP_TOKEN`) and
146
+ * `?revision=<commit>`, the app bundle (`AppBundle` in `@sanoma-ai/app`) a host's Worker script runs
147
+ * at that revision, built in a checkout of its own: the Cloudflare host asks for it at a deploy.
147
148
  */
148
- export const SITES_BUNDLE_PATH = "/__sanoma/sites-bundle";
149
- /** The header every answer of that route carries: a runtime without the route answers without it. */
150
- export const SITES_BUNDLE_HEADER = "x-sanoma-sites-bundle";
151
-
152
- /** What that route answers: what a host's Worker runs a company's public sites from (`buildWorker` in `@sanoma-ai/app/build`). */
153
- export interface SitesBundle {
154
- /** The sites it serves, by key: public, with a domain and no login. */
155
- sites: string[];
156
- /** The main module, among `modules`. */
157
- mainModule: string;
158
- /** The Worker's modules, by name relative to the main module, each base64. */
159
- modules: { name: string; type: "esm" | "wasm" | "text" | "data"; content: string }[];
160
- /** The Worker's static assets, by path from `/`, each base64: the client build and the prerendered pages. */
161
- assets: { path: string; content: string }[];
162
- }
149
+ export const APP_BUNDLE_PATH = "/__sanoma/app-bundle";
150
+ /** The header every answer of that route carries, so a host tells them from its own errors. */
151
+ export const APP_BUNDLE_HEADER = "x-sanoma-app-bundle";
163
152
 
164
153
  /** Where a host with a `hostname` serves the app (`HostSpec`'s `hostname`): `https://<hostname>`. */
165
154
  export const hostnameUrl = (hostname: string): string => `https://${hostname}`;
@@ -1,5 +1,8 @@
1
1
  import { createHash } from "node:crypto";
2
- import { Client, escapeIdentifier } from "pg";
2
+ import { eq, sql } from "drizzle-orm";
3
+ import { Client } from "pg";
4
+ import { databaseOn, type Db, pgCode } from "./db.ts";
5
+ import { kmsDeployment, kmsSecrets } from "./db/kms.ts";
3
6
  import { KMS_SECRETS_SQL } from "./kms-secrets.ts";
4
7
  import { generateSecret, type Secrets } from "./secret.ts";
5
8
 
@@ -47,7 +50,8 @@ const fail = (code: StorageProvisioningError["code"]): never => {
47
50
  };
48
51
  const digest = (value: string) => createHash("sha256").update(value).digest("hex");
49
52
  const ROLE_PREFIX = "sanoma_runtime_";
50
- const BINDING_SQL = `CREATE TABLE IF NOT EXISTS sanoma_kms.deployment (
53
+ /** Installs the deployment binding table beside `KMS_SECRETS_SQL`; an administrator creates it, the runtime never does. */
54
+ export const BINDING_SQL = `CREATE TABLE IF NOT EXISTS sanoma_kms.deployment (
51
55
  singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
52
56
  company text NOT NULL, environment text NOT NULL, runtime_role text NOT NULL,
53
57
  credential_hash text NOT NULL
@@ -128,7 +132,7 @@ async function credentials(input: PrepareSecretStore, secrets: Secrets) {
128
132
  };
129
133
  }
130
134
 
131
- async function connected<T>(url: string, run: (db: Client) => Promise<T>): Promise<T> {
135
+ async function connected<T>(url: string, run: (db: Db) => Promise<T>): Promise<T> {
132
136
  const db = new Client({
133
137
  connectionString: url,
134
138
  connectionTimeoutMillis: 10_000,
@@ -140,7 +144,7 @@ async function connected<T>(url: string, run: (db: Client) => Promise<T>): Promi
140
144
  });
141
145
  try {
142
146
  await db.connect();
143
- return await run(db);
147
+ return await run(databaseOn(db));
144
148
  } finally {
145
149
  await db.end().catch(() => {});
146
150
  }
@@ -152,72 +156,65 @@ export const prepareSecretStore: PrepareStorage = async (input, secrets) => {
152
156
  const prepared = await credentials(input, secrets);
153
157
  const { company, environment } = input;
154
158
  const hash = digest(prepared.password),
155
- role = escapeIdentifier(prepared.role);
156
- await connected(prepared.admin, async (db) => {
157
- await db.query("BEGIN");
158
- try {
159
- await db.query("SELECT pg_advisory_xact_lock(hashtext('sanoma.prepareSecretStore'))");
160
- await db.query(KMS_SECRETS_SQL);
161
- await db.query(BINDING_SQL);
162
- const existing = (
163
- await db.query<{ company: string; environment: string; runtime_role: string; credential_hash: string }>(
164
- "SELECT company,environment,runtime_role,credential_hash FROM sanoma_kms.deployment WHERE singleton=true FOR UPDATE",
165
- )
166
- ).rows[0];
159
+ role = sql.identifier(prepared.role);
160
+ await connected(prepared.admin, (db) =>
161
+ db.transaction(async (tx) => {
162
+ await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('sanoma.prepareSecretStore'))`);
163
+ await tx.execute(sql.raw(KMS_SECRETS_SQL));
164
+ await tx.execute(sql.raw(BINDING_SQL));
165
+ const [existing] = await tx.select().from(kmsDeployment).where(eq(kmsDeployment.singleton, true)).for("update");
167
166
  if (existing) {
168
167
  if (
169
168
  existing.company !== company ||
170
169
  existing.environment !== environment ||
171
- existing.runtime_role !== prepared.role ||
172
- existing.credential_hash !== hash
170
+ existing.runtimeRole !== prepared.role ||
171
+ existing.credentialHash !== hash
173
172
  )
174
173
  fail("conflict");
175
174
  } else {
176
175
  if (
177
- (await db.query("SELECT 1 FROM pg_roles WHERE rolname=$1", [prepared.role])).rowCount ||
178
- (await db.query("SELECT 1 FROM sanoma_kms.secrets LIMIT 1")).rowCount
176
+ (await tx.execute(sql`SELECT 1 FROM pg_roles WHERE rolname = ${prepared.role}`)).rowCount ||
177
+ (
178
+ await tx
179
+ .select({ one: sql`1` })
180
+ .from(kmsSecrets)
181
+ .limit(1)
182
+ ).length
179
183
  )
180
184
  fail("conflict");
181
- await db.query("SET LOCAL password_encryption = 'scram-sha-256'");
182
- await db.query(CREATE_RUNTIME_SQL);
183
- await db.query("SELECT pg_temp.sanoma_create_runtime($1, $2)", [prepared.role, prepared.password]);
184
- await db.query(
185
- "INSERT INTO sanoma_kms.deployment(company,environment,runtime_role,credential_hash) VALUES($1,$2,$3,$4)",
186
- [company, environment, prepared.role, hash],
187
- );
185
+ await tx.execute(sql`SET LOCAL password_encryption = 'scram-sha-256'`);
186
+ await tx.execute(sql.raw(CREATE_RUNTIME_SQL));
187
+ await tx.execute(sql`SELECT pg_temp.sanoma_create_runtime(${prepared.role}, ${prepared.password})`);
188
+ await tx
189
+ .insert(kmsDeployment)
190
+ .values({ company, environment, runtimeRole: prepared.role, credentialHash: hash });
188
191
  }
189
192
  // Never silently repair/adopt an unexpectedly privileged role on a retry.
190
193
  const authority = (
191
- await db.query<{ safe: boolean }>(
192
- `SELECT (
194
+ await tx.execute<{ safe: boolean }>(sql`SELECT (
193
195
  NOT r.rolsuper AND NOT r.rolcreatedb AND NOT r.rolcreaterole AND NOT r.rolreplication AND NOT r.rolbypassrls
194
196
  AND r.rolcanlogin AND NOT r.rolinherit
195
197
  AND NOT EXISTS(SELECT 1 FROM pg_auth_members WHERE member=r.oid)
196
198
  AND NOT EXISTS(SELECT 1 FROM pg_database WHERE datdba=r.oid)
197
199
  AND NOT EXISTS(SELECT 1 FROM pg_namespace WHERE nspname='sanoma_kms' AND nspowner=r.oid)
198
- ) AS safe FROM pg_roles r WHERE rolname=$1`,
199
- [prepared.role],
200
- )
200
+ ) AS safe FROM pg_roles r WHERE rolname = ${prepared.role}`)
201
201
  ).rows[0];
202
202
  if (!authority?.safe) fail("denied");
203
- const database = (await db.query<{ name: string }>("SELECT current_database() AS name")).rows[0]!.name;
204
- await db.query(`REVOKE ALL ON DATABASE ${escapeIdentifier(database)} FROM PUBLIC`);
205
- await db.query(`GRANT CONNECT, CREATE, TEMPORARY ON DATABASE ${escapeIdentifier(database)} TO ${role}`);
206
- await db.query("REVOKE ALL ON SCHEMA public FROM PUBLIC");
207
- await db.query(`GRANT USAGE, CREATE ON SCHEMA public TO ${role}`);
208
- await db.query(`GRANT USAGE ON SCHEMA sanoma_kms TO ${role}`);
209
- await db.query(`GRANT SELECT, INSERT, UPDATE, DELETE ON sanoma_kms.secrets TO ${role}`);
210
- await db.query("COMMIT");
211
- } catch (error) {
212
- await db.query("ROLLBACK").catch(() => {});
213
- throw error;
214
- }
215
- });
203
+ const database = sql.identifier(
204
+ (await tx.execute<{ name: string }>(sql`SELECT current_database() AS name`)).rows[0]!.name,
205
+ );
206
+ await tx.execute(sql`REVOKE ALL ON DATABASE ${database} FROM PUBLIC`);
207
+ await tx.execute(sql`GRANT CONNECT, CREATE, TEMPORARY ON DATABASE ${database} TO ${role}`);
208
+ await tx.execute(sql`REVOKE ALL ON SCHEMA public FROM PUBLIC`);
209
+ await tx.execute(sql`GRANT USAGE, CREATE ON SCHEMA public TO ${role}`);
210
+ await tx.execute(sql`GRANT USAGE ON SCHEMA sanoma_kms TO ${role}`);
211
+ await tx.execute(sql`GRANT SELECT, INSERT, UPDATE, DELETE ON sanoma_kms.secrets TO ${role}`);
212
+ }),
213
+ );
216
214
  // A committed schema is not enough: the stored login must work with the promised authority.
217
215
  await connected(prepared.runtime, async (db) => {
218
- const { rows } = await db.query<{ safe: boolean }>(
219
- `SELECT (
220
- current_user=$1 AND has_schema_privilege(current_user,'sanoma_kms','USAGE')
216
+ const { rows } = await db.execute<{ safe: boolean }>(sql`SELECT (
217
+ current_user = ${prepared.role} AND has_schema_privilege(current_user,'sanoma_kms','USAGE')
221
218
  AND NOT has_schema_privilege(current_user,'sanoma_kms','CREATE')
222
219
  AND has_table_privilege(current_user,'sanoma_kms.secrets','SELECT')
223
220
  AND has_table_privilege(current_user,'sanoma_kms.secrets','INSERT')
@@ -225,16 +222,14 @@ export const prepareSecretStore: PrepareStorage = async (input, secrets) => {
225
222
  AND has_table_privilege(current_user,'sanoma_kms.secrets','DELETE')
226
223
  AND NOT has_table_privilege(current_user,'sanoma_kms.secrets','TRUNCATE,TRIGGER,REFERENCES')
227
224
  AND NOT has_table_privilege(current_user,'sanoma_kms.deployment','SELECT,INSERT,UPDATE,DELETE,TRUNCATE,TRIGGER,REFERENCES')
228
- ) AS safe`,
229
- [prepared.role],
230
- );
225
+ ) AS safe`);
231
226
  if (!rows[0]?.safe) fail("denied");
232
227
  });
233
228
  return prepared.receipt;
234
229
  } catch (error) {
235
230
  const known = storageProvisioningCode(error);
236
231
  if (known) throw new StorageProvisioningError(known);
237
- const code = (error as { code?: string } | null)?.code;
232
+ const code = pgCode(error);
238
233
  throw new StorageProvisioningError(
239
234
  code === "42501" ? "denied" : code === "23505" || code === "42710" ? "conflict" : "unavailable",
240
235
  );
package/src/tokens.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import type { Pool } from "pg";
3
- import { API_TOKENS_TABLE as TABLE } from "./db.ts";
2
+ import { and, eq, isNull, type SQL, sql } from "drizzle-orm";
3
+ import { pgCode, type Db } from "./db.ts";
4
+ import { apiTokens } from "./db/company.ts";
4
5
  import type { Principal } from "./define.ts";
5
6
  import { generateSecret, tokenHash as hashOf } from "./secret.ts";
6
7
 
@@ -48,62 +49,71 @@ export interface Tokens {
48
49
  subjectOf(id: string): Promise<string | undefined>;
49
50
  }
50
51
 
51
- export function tokenStore(db: () => Promise<Pool>): Tokens {
52
+ const active = (where: SQL | undefined) => and(where, isNull(apiTokens.revokedAt));
53
+
54
+ export function tokenStore(db: () => Promise<Db>): Tokens {
52
55
  return {
53
56
  async mint({ id = randomUUID(), subject, groups }) {
54
57
  const token = newApiToken();
55
- await (
56
- await db()
57
- ).query(`INSERT INTO ${TABLE} (id, hash, subject, groups) VALUES ($1, $2, $3, $4::jsonb)`, [
58
- id,
59
- hashOf(token),
60
- subject,
61
- JSON.stringify(groups),
62
- ]);
58
+ await (await db()).insert(apiTokens).values({ id, hash: hashOf(token), subject, groups: [...groups] });
63
59
  return { id, token };
64
60
  },
65
61
  async register(input) {
66
62
  const { id, hash, subject, groups } = registrationOf(input);
67
63
  try {
68
- const result = await (
64
+ // A duplicate id changes nothing unless it is this same active registration (the no-op
65
+ // update makes the row come back).
66
+ const registered = await (
69
67
  await db()
70
- ).query(
71
- `INSERT INTO ${TABLE} (id, hash, subject, groups) VALUES ($1,$2,$3,$4::jsonb)
72
- ON CONFLICT (id) DO UPDATE SET id=EXCLUDED.id
73
- WHERE ${TABLE}.hash=EXCLUDED.hash AND ${TABLE}.subject=EXCLUDED.subject
74
- AND ${TABLE}.groups @> EXCLUDED.groups AND ${TABLE}.groups <@ EXCLUDED.groups
75
- AND ${TABLE}.revoked_at IS NULL
76
- RETURNING id`,
77
- [id, hash, subject, JSON.stringify(groups)],
78
- );
79
- return result.rowCount === 1;
68
+ )
69
+ .insert(apiTokens)
70
+ .values({ id, hash, subject, groups })
71
+ .onConflictDoUpdate({
72
+ target: apiTokens.id,
73
+ set: { id },
74
+ setWhere: and(
75
+ eq(apiTokens.hash, hash),
76
+ eq(apiTokens.subject, subject),
77
+ sql`${apiTokens.groups} @> ${sql.param(groups, apiTokens.groups)} AND ${apiTokens.groups} <@ ${sql.param(groups, apiTokens.groups)}`,
78
+ isNull(apiTokens.revokedAt),
79
+ ),
80
+ })
81
+ .returning({ id: apiTokens.id });
82
+ return registered.length === 1;
80
83
  } catch (error) {
81
84
  // A bearer already registered under another ID cannot acquire a second identity.
82
- if ((error as { code?: string }).code === "23505") return false;
85
+ if (pgCode(error) === "23505") return false;
83
86
  throw error;
84
87
  }
85
88
  },
86
89
  async revoke({ id }) {
87
- await (await db()).query(`UPDATE ${TABLE} SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL`, [id]);
90
+ await (
91
+ await db()
92
+ )
93
+ .update(apiTokens)
94
+ .set({ revokedAt: sql`now()` })
95
+ .where(active(eq(apiTokens.id, id)));
88
96
  },
89
97
  async resolve(token) {
90
98
  if (!token.startsWith(PREFIX)) return undefined;
91
99
  // Looked up by its hash: the token itself is never stored, so there is nothing to compare it to.
92
- const { rows } = await (
100
+ const [row] = await (
93
101
  await db()
94
- ).query<{ id: string; subject: string; groups: string[] }>(
95
- `SELECT id, subject, groups FROM ${TABLE} WHERE hash = $1 AND revoked_at IS NULL`,
96
- [hashOf(token)],
97
- );
98
- const row = rows[0];
102
+ )
103
+ .select({ id: apiTokens.id, subject: apiTokens.subject, groups: apiTokens.groups })
104
+ .from(apiTokens)
105
+ .where(active(eq(apiTokens.hash, hashOf(token))));
99
106
  if (!row) return undefined;
100
107
  return { principal: { id: `token:${row.id}`, groups: row.groups }, subject: row.subject };
101
108
  },
102
109
  async subjectOf(id) {
103
- const { rows } = await (
110
+ const [row] = await (
104
111
  await db()
105
- ).query<{ subject: string }>(`SELECT subject FROM ${TABLE} WHERE id = $1 AND revoked_at IS NULL`, [id]);
106
- return rows[0]?.subject;
112
+ )
113
+ .select({ subject: apiTokens.subject })
114
+ .from(apiTokens)
115
+ .where(active(eq(apiTokens.id, id)));
116
+ return row?.subject;
107
117
  },
108
118
  };
109
119
  }
package/src/version.ts CHANGED
@@ -69,14 +69,14 @@ export function computeVersion({
69
69
  }
70
70
 
71
71
  /**
72
- * A hash of the built-in `change` workflow's own code, which each change run carries (its
73
- * `changeCode` attribute) so a worker on another version of the app may take it over
74
- * (`carryChangeRuns` in worker.ts): what `computeVersion` hashes of that one workflow, with
72
+ * A hash of a built-in workflow's own code (`apply`'s, `change`'s, …), which each of its runs
73
+ * carries (its `builtInCode` attribute) so a worker on another version of the app may take it
74
+ * over (`carryBuiltInRuns` in worker.ts): what `computeVersion` hashes of that one workflow, with
75
75
  * `RUNTIME_VERSION`, `STEP_LAYOUT` and the installed DBOS version. Never the company's workflows
76
76
  * nor `DBOS__APPVERSION`, so a merge that changes only the company's code leaves it as it is.
77
77
  */
78
- export function changeCodeOf(change: WorkflowDefinition<any, any>): string {
79
- return sha256([RUNTIME_VERSION, STEP_LAYOUT, installedDbosVersion(), codeOf(change)]);
78
+ export function builtInCodeOf(builtIn: WorkflowDefinition<any, any>): string {
79
+ return sha256([RUNTIME_VERSION, STEP_LAYOUT, installedDbosVersion(), codeOf(builtIn)]);
80
80
  }
81
81
 
82
82
  const sha256 = (parts: unknown[]) => createHash("sha256").update(JSON.stringify(parts)).digest("hex");