@sanoma-ai/workflows 0.10.1 → 0.10.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLOUD-IDENTITY.md +140 -0
- package/KMS-SECRETS.md +99 -0
- package/README.md +49 -11
- package/SECRET-SUPPORT.md +1 -1
- package/dist/apply.js +1 -1
- package/dist/apply.js.map +1 -1
- package/dist/authentication.d.ts +7 -2
- package/dist/authentication.js +2 -2
- package/dist/authentication.js.map +1 -1
- package/dist/call.d.ts +5 -2
- package/dist/call.js +65 -20
- package/dist/call.js.map +1 -1
- package/dist/change.d.ts +5 -6
- package/dist/change.js +6 -9
- package/dist/change.js.map +1 -1
- package/dist/client.d.ts +18 -0
- package/dist/client.js +37 -5
- package/dist/client.js.map +1 -1
- package/dist/cloud-identity.d.ts +52 -0
- package/dist/cloud-identity.js +222 -0
- package/dist/cloud-identity.js.map +1 -0
- package/dist/config.d.ts +42 -4
- package/dist/config.js +71 -10
- package/dist/config.js.map +1 -1
- package/dist/connection.js +8 -2
- package/dist/connection.js.map +1 -1
- package/dist/db.d.ts +2 -0
- package/dist/db.js +7 -0
- package/dist/db.js.map +1 -1
- package/dist/deadlines.d.ts +26 -13
- package/dist/deadlines.js +50 -11
- package/dist/deadlines.js.map +1 -1
- package/dist/define.d.ts +22 -0
- package/dist/define.js +2 -0
- package/dist/define.js.map +1 -1
- package/dist/deployment-key.d.ts +20 -1
- package/dist/deployment-key.js +28 -3
- package/dist/deployment-key.js.map +1 -1
- package/dist/describe.js +28 -1
- package/dist/describe.js.map +1 -1
- package/dist/description.d.ts +3 -0
- package/dist/description.js +2 -0
- package/dist/description.js.map +1 -1
- package/dist/host.d.ts +13 -4
- package/dist/host.js +14 -5
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +11 -7
- package/dist/index.js +8 -5
- package/dist/index.js.map +1 -1
- package/dist/kms-secrets.d.ts +24 -0
- package/dist/kms-secrets.js +209 -0
- package/dist/kms-secrets.js.map +1 -0
- package/dist/ledger.d.ts +30 -1
- package/dist/ledger.js +31 -0
- package/dist/ledger.js.map +1 -1
- package/dist/login-code.d.ts +17 -0
- package/dist/login-code.js +32 -0
- package/dist/login-code.js.map +1 -0
- package/dist/op.d.ts +78 -10
- package/dist/op.js +20 -0
- package/dist/op.js.map +1 -1
- package/dist/outline.js +3 -3
- package/dist/outline.js.map +1 -1
- package/dist/resource.d.ts +6 -4
- package/dist/resource.js +8 -5
- package/dist/resource.js.map +1 -1
- package/dist/resources.d.ts +4 -2
- package/dist/resources.js +15 -6
- package/dist/resources.js.map +1 -1
- package/dist/run.d.ts +3 -0
- package/dist/run.js.map +1 -1
- package/dist/runtime-ops.d.ts +123 -0
- package/dist/runtime-ops.js +222 -0
- package/dist/runtime-ops.js.map +1 -0
- package/dist/seal.d.ts +27 -0
- package/dist/seal.js +76 -0
- package/dist/seal.js.map +1 -0
- package/dist/secret-store.d.ts +6 -4
- package/dist/secret-store.js +11 -6
- package/dist/secret-store.js.map +1 -1
- package/dist/secret.d.ts +7 -0
- package/dist/secret.js +13 -1
- package/dist/secret.js.map +1 -1
- package/dist/shared.d.ts +15 -4
- package/dist/shared.js +27 -7
- package/dist/shared.js.map +1 -1
- package/dist/ssh-key.d.ts +11 -0
- package/dist/ssh-key.js +44 -0
- package/dist/ssh-key.js.map +1 -0
- package/dist/tokens.d.ts +35 -0
- package/dist/tokens.js +71 -0
- package/dist/tokens.js.map +1 -0
- package/dist/wakeup-service.js +2 -7
- package/dist/wakeup-service.js.map +1 -1
- package/dist/wakeup.d.ts +18 -0
- package/dist/wakeup.js +15 -0
- package/dist/wakeup.js.map +1 -1
- package/dist/worker.d.ts +2 -2
- package/dist/worker.js +17 -9
- package/dist/worker.js.map +1 -1
- package/oxlint.json +3 -1
- package/package.json +15 -2
- package/src/apply.ts +1 -1
- package/src/authentication.ts +9 -2
- package/src/call.ts +88 -18
- package/src/change.ts +9 -20
- package/src/client.ts +50 -7
- package/src/cloud-identity.ts +266 -0
- package/src/config.ts +111 -17
- package/src/connection.ts +8 -2
- package/src/db.ts +8 -1
- package/src/deadlines.ts +66 -20
- package/src/define.ts +25 -0
- package/src/deployment-key.ts +34 -8
- package/src/describe.ts +39 -2
- package/src/description.ts +2 -0
- package/src/host.ts +22 -6
- package/src/index.ts +19 -1
- package/src/kms-secrets.ts +271 -0
- package/src/ledger.ts +52 -1
- package/src/login-code.ts +52 -0
- package/src/op.ts +84 -11
- package/src/outline.ts +3 -3
- package/src/resource.ts +8 -5
- package/src/resources.ts +13 -4
- package/src/run.ts +3 -0
- package/src/runtime-ops.ts +234 -0
- package/src/seal.ts +95 -0
- package/src/secret-store.ts +12 -9
- package/src/secret.ts +16 -1
- package/src/shared.ts +31 -7
- package/src/ssh-key.ts +48 -0
- package/src/tokens.ts +100 -0
- package/src/wakeup-service.ts +2 -8
- package/src/wakeup.ts +21 -0
- package/src/worker.ts +29 -11
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { Compute, OAuth2Client } from "google-auth-library";
|
|
3
|
+
import type { Db } from "./db.ts";
|
|
4
|
+
|
|
5
|
+
const LIFETIME = 600_000;
|
|
6
|
+
const SCOPE = "https://www.googleapis.com/auth/cloud-platform";
|
|
7
|
+
const CREDENTIAL = /^[A-Za-z0-9_-]{43}$/;
|
|
8
|
+
const ACCOUNT = /^[a-z][a-z0-9-]+@[a-z][a-z0-9-]+\.iam\.gserviceaccount\.com$/;
|
|
9
|
+
|
|
10
|
+
/** A safe error boundary: no HTTP response, credentials, SDK cause or request options. */
|
|
11
|
+
export class CloudIdentityError extends Error {
|
|
12
|
+
constructor() {
|
|
13
|
+
super("Cloud identity unavailable");
|
|
14
|
+
this.name = "CloudIdentityError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Provisioning only. Deliver the value once through deployment secrets; persist only its hash. */
|
|
19
|
+
export function createDeploymentCredential(): { credential: string; hash: string } {
|
|
20
|
+
const credential = randomBytes(32).toString("base64url");
|
|
21
|
+
return { credential, hash: credentialHash(credential) };
|
|
22
|
+
}
|
|
23
|
+
const credentialHash = (credential: string) => createHash("sha256").update(credential).digest("hex");
|
|
24
|
+
|
|
25
|
+
export interface IdentityEnrollment {
|
|
26
|
+
deployment: string;
|
|
27
|
+
company: string;
|
|
28
|
+
environment: string;
|
|
29
|
+
serviceAccount: string;
|
|
30
|
+
generation: number;
|
|
31
|
+
}
|
|
32
|
+
export interface IdentityEnrollments {
|
|
33
|
+
/** Return an enrollment only while this hash/generation and deployment are active. Never cache. */
|
|
34
|
+
active(hash: string): Promise<IdentityEnrollment | undefined>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Install in the broker's own database using provisioning authority, never a company database. */
|
|
38
|
+
export const CLOUD_IDENTITY_SQL = `
|
|
39
|
+
CREATE SCHEMA IF NOT EXISTS sanoma_identity;
|
|
40
|
+
REVOKE ALL ON SCHEMA sanoma_identity FROM PUBLIC;
|
|
41
|
+
CREATE TABLE IF NOT EXISTS sanoma_identity.deployments (
|
|
42
|
+
id text PRIMARY KEY, company text NOT NULL, environment text NOT NULL,
|
|
43
|
+
service_account text NOT NULL, enabled boolean NOT NULL DEFAULT true
|
|
44
|
+
);
|
|
45
|
+
CREATE TABLE IF NOT EXISTS sanoma_identity.credentials (
|
|
46
|
+
hash text PRIMARY KEY CHECK (hash ~ '^[0-9a-f]{64}$'),
|
|
47
|
+
deployment text NOT NULL REFERENCES sanoma_identity.deployments(id),
|
|
48
|
+
generation bigint NOT NULL CHECK (generation > 0),
|
|
49
|
+
valid_from timestamptz NOT NULL DEFAULT now(), valid_until timestamptz NOT NULL,
|
|
50
|
+
revoked boolean NOT NULL DEFAULT false,
|
|
51
|
+
UNIQUE (deployment, generation), CHECK (valid_until > valid_from),
|
|
52
|
+
CHECK (isfinite(valid_from) AND isfinite(valid_until))
|
|
53
|
+
);
|
|
54
|
+
REVOKE ALL ON ALL TABLES IN SCHEMA sanoma_identity FROM PUBLIC;
|
|
55
|
+
`;
|
|
56
|
+
|
|
57
|
+
/** Give the broker SELECT only; enrollment, rotation and revocation use a separate database role. */
|
|
58
|
+
export function postgresIdentityEnrollments(database: () => Promise<Db>): IdentityEnrollments {
|
|
59
|
+
return {
|
|
60
|
+
async active(hash) {
|
|
61
|
+
const { rows } = await (
|
|
62
|
+
await database()
|
|
63
|
+
).query<{
|
|
64
|
+
deployment: string;
|
|
65
|
+
company: string;
|
|
66
|
+
environment: string;
|
|
67
|
+
service_account: string;
|
|
68
|
+
generation: string;
|
|
69
|
+
}>(
|
|
70
|
+
`SELECT d.id AS deployment, d.company, d.environment, d.service_account, c.generation
|
|
71
|
+
FROM sanoma_identity.credentials c JOIN sanoma_identity.deployments d ON d.id = c.deployment
|
|
72
|
+
WHERE c.hash = $1 AND NOT c.revoked AND d.enabled
|
|
73
|
+
AND c.valid_from <= clock_timestamp() AND c.valid_until > clock_timestamp()`,
|
|
74
|
+
[hash],
|
|
75
|
+
);
|
|
76
|
+
const row = rows[0];
|
|
77
|
+
return (
|
|
78
|
+
row && {
|
|
79
|
+
deployment: row.deployment,
|
|
80
|
+
company: row.company,
|
|
81
|
+
environment: row.environment,
|
|
82
|
+
serviceAccount: row.service_account,
|
|
83
|
+
generation: Number(row.generation),
|
|
84
|
+
}
|
|
85
|
+
);
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface CloudAccessToken {
|
|
91
|
+
accessToken: string;
|
|
92
|
+
expiresAt: number;
|
|
93
|
+
}
|
|
94
|
+
export interface IdentityAudit {
|
|
95
|
+
result: "issued" | "denied" | "unavailable";
|
|
96
|
+
enrollment?: IdentityEnrollment;
|
|
97
|
+
expiresAt?: number;
|
|
98
|
+
}
|
|
99
|
+
export interface IdentityBrokerOptions {
|
|
100
|
+
enrollments: IdentityEnrollments;
|
|
101
|
+
/** Trusted issuer with attached platform authority; target comes exclusively from enrollment. */
|
|
102
|
+
issue(serviceAccount: string): Promise<CloudAccessToken>;
|
|
103
|
+
/** Must durably record the decision without token values. A failure prevents delivery. */
|
|
104
|
+
audit(event: IdentityAudit): Promise<void>;
|
|
105
|
+
}
|
|
106
|
+
const validEnrollment = (e: IdentityEnrollment | undefined): e is IdentityEnrollment =>
|
|
107
|
+
!!e &&
|
|
108
|
+
[e.deployment, e.company, e.environment].every((v) => typeof v === "string" && v.length > 0 && v.length <= 256) &&
|
|
109
|
+
ACCOUNT.test(e.serviceAccount) &&
|
|
110
|
+
Number.isSafeInteger(e.generation) &&
|
|
111
|
+
e.generation > 0;
|
|
112
|
+
const validToken = (token: CloudAccessToken, now: number) =>
|
|
113
|
+
typeof token?.accessToken === "string" &&
|
|
114
|
+
token.accessToken.length > 0 &&
|
|
115
|
+
token.accessToken.length <= 8192 &&
|
|
116
|
+
!/[\s\r\n]/.test(token.accessToken) &&
|
|
117
|
+
Number.isFinite(token.expiresAt) &&
|
|
118
|
+
token.expiresAt > now &&
|
|
119
|
+
token.expiresAt <= now + LIFETIME + 30_000;
|
|
120
|
+
const reply = (status: number, body: unknown) =>
|
|
121
|
+
Response.json(body, {
|
|
122
|
+
status,
|
|
123
|
+
headers: { "Cache-Control": "no-store", Pragma: "no-cache" },
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Node/edge adapters can represent an empty POST as a stream. Reject actual bytes,
|
|
127
|
+
// without buffering attacker-controlled input or waiting indefinitely for its end.
|
|
128
|
+
async function hasBody(request: Request): Promise<boolean> {
|
|
129
|
+
if (!request.body) return false;
|
|
130
|
+
const reader = request.body.getReader();
|
|
131
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
132
|
+
try {
|
|
133
|
+
const first = await Promise.race([
|
|
134
|
+
reader.read(),
|
|
135
|
+
new Promise<never>((_, reject) => {
|
|
136
|
+
timeout = setTimeout(() => reject(new CloudIdentityError()), 5_000);
|
|
137
|
+
}),
|
|
138
|
+
]);
|
|
139
|
+
return !first.done;
|
|
140
|
+
} finally {
|
|
141
|
+
clearTimeout(timeout);
|
|
142
|
+
void reader.cancel().catch(() => {});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** POST /token with a deployment bearer credential and no query or body. No enrollment endpoint. */
|
|
147
|
+
export function identityBroker(options: IdentityBrokerOptions): (request: Request) => Promise<Response> {
|
|
148
|
+
return async (request) => {
|
|
149
|
+
let enrollment: IdentityEnrollment | undefined;
|
|
150
|
+
try {
|
|
151
|
+
const url = new URL(request.url);
|
|
152
|
+
if (url.pathname !== "/token" || request.method !== "POST") return reply(404, { error: "not_found" });
|
|
153
|
+
if (url.search || (await hasBody(request))) return reply(400, { error: "invalid_request" });
|
|
154
|
+
const authorization = request.headers.get("authorization") ?? "";
|
|
155
|
+
const credential = authorization.startsWith("Bearer ") ? authorization.slice(7) : "";
|
|
156
|
+
const hash = CREDENTIAL.test(credential) ? credentialHash(credential) : undefined;
|
|
157
|
+
enrollment = hash ? await options.enrollments.active(hash) : undefined;
|
|
158
|
+
if (!validEnrollment(enrollment)) {
|
|
159
|
+
await options.audit({ result: "denied" });
|
|
160
|
+
return reply(401, { error: "unauthorized" });
|
|
161
|
+
}
|
|
162
|
+
// Copy authoritative values before any awaits; caller fields never select a target or lifetime.
|
|
163
|
+
enrollment = {
|
|
164
|
+
deployment: enrollment.deployment,
|
|
165
|
+
company: enrollment.company,
|
|
166
|
+
environment: enrollment.environment,
|
|
167
|
+
serviceAccount: enrollment.serviceAccount,
|
|
168
|
+
generation: enrollment.generation,
|
|
169
|
+
};
|
|
170
|
+
const token = await options.issue(enrollment.serviceAccount);
|
|
171
|
+
if (!validToken(token, Date.now())) throw new CloudIdentityError();
|
|
172
|
+
const current = await options.enrollments.active(hash!);
|
|
173
|
+
if (
|
|
174
|
+
!current ||
|
|
175
|
+
(Object.keys(enrollment) as (keyof IdentityEnrollment)[]).some((key) => current[key] !== enrollment![key])
|
|
176
|
+
) {
|
|
177
|
+
await options.audit({ result: "denied", enrollment });
|
|
178
|
+
return reply(401, { error: "unauthorized" });
|
|
179
|
+
}
|
|
180
|
+
await options.audit({ result: "issued", enrollment, expiresAt: token.expiresAt });
|
|
181
|
+
return reply(200, { accessToken: token.accessToken, expiresAt: token.expiresAt });
|
|
182
|
+
} catch {
|
|
183
|
+
try {
|
|
184
|
+
await options.audit({ result: "unavailable", ...(enrollment && { enrollment }) });
|
|
185
|
+
} catch {
|
|
186
|
+
/* fail closed */
|
|
187
|
+
}
|
|
188
|
+
return reply(503, { error: "unavailable" });
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Cloud Run attached identity only: no ADC environment file or service-account private key fallback. */
|
|
194
|
+
export function attachedGoogleTokenIssuer(): IdentityBrokerOptions["issue"] {
|
|
195
|
+
const auth = new Compute({ scopes: [SCOPE] });
|
|
196
|
+
return async (serviceAccount) => {
|
|
197
|
+
if (!ACCOUNT.test(serviceAccount)) throw new CloudIdentityError();
|
|
198
|
+
try {
|
|
199
|
+
const response = await auth.request<{ accessToken: string; expireTime: string }>({
|
|
200
|
+
url: `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${serviceAccount}:generateAccessToken`,
|
|
201
|
+
method: "POST",
|
|
202
|
+
data: { scope: [SCOPE], lifetime: "600s" },
|
|
203
|
+
timeout: 10_000,
|
|
204
|
+
});
|
|
205
|
+
const token = { accessToken: response.data.accessToken, expiresAt: Date.parse(response.data.expireTime) };
|
|
206
|
+
if (!validToken(token, Date.now())) throw new CloudIdentityError();
|
|
207
|
+
return token;
|
|
208
|
+
} catch {
|
|
209
|
+
throw new CloudIdentityError();
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** A per-runtime Google SDK auth client. Short-lived access tokens are kept only in this instance's memory. */
|
|
215
|
+
export function cloudIdentityAuth(options: {
|
|
216
|
+
endpoint: string;
|
|
217
|
+
/** Read the current deployment credential from protected bootstrap; called again on renewal. */
|
|
218
|
+
credential(): Promise<string>;
|
|
219
|
+
}): OAuth2Client {
|
|
220
|
+
let endpoint: URL;
|
|
221
|
+
try {
|
|
222
|
+
endpoint = new URL(options.endpoint);
|
|
223
|
+
} catch {
|
|
224
|
+
throw new CloudIdentityError();
|
|
225
|
+
}
|
|
226
|
+
if (
|
|
227
|
+
endpoint.protocol !== "https:" ||
|
|
228
|
+
endpoint.username ||
|
|
229
|
+
endpoint.password ||
|
|
230
|
+
endpoint.search ||
|
|
231
|
+
endpoint.hash ||
|
|
232
|
+
endpoint.pathname !== "/token"
|
|
233
|
+
)
|
|
234
|
+
throw new CloudIdentityError();
|
|
235
|
+
const client = new OAuth2Client({ eagerRefreshThresholdMillis: 60_000 });
|
|
236
|
+
let pending: Promise<{ access_token: string; expiry_date: number }> | undefined;
|
|
237
|
+
client.refreshHandler = () => {
|
|
238
|
+
if (pending) return pending;
|
|
239
|
+
pending = (async () => {
|
|
240
|
+
try {
|
|
241
|
+
const credential = await options.credential();
|
|
242
|
+
if (!CREDENTIAL.test(credential)) throw new CloudIdentityError();
|
|
243
|
+
const response = await fetch(endpoint, {
|
|
244
|
+
method: "POST",
|
|
245
|
+
headers: { Authorization: `Bearer ${credential}` },
|
|
246
|
+
cache: "no-store",
|
|
247
|
+
redirect: "error",
|
|
248
|
+
signal: AbortSignal.timeout(10_000),
|
|
249
|
+
});
|
|
250
|
+
if (!response.ok) throw new CloudIdentityError();
|
|
251
|
+
const raw = await response.text();
|
|
252
|
+
if (raw.length > 16_384) throw new CloudIdentityError();
|
|
253
|
+
const token = JSON.parse(raw) as CloudAccessToken;
|
|
254
|
+
const now = Date.now();
|
|
255
|
+
if (!validToken(token, now) || token.expiresAt <= now + 60_000) throw new CloudIdentityError();
|
|
256
|
+
return { access_token: token.accessToken, expiry_date: token.expiresAt };
|
|
257
|
+
} catch {
|
|
258
|
+
throw new CloudIdentityError();
|
|
259
|
+
} finally {
|
|
260
|
+
pending = undefined;
|
|
261
|
+
}
|
|
262
|
+
})();
|
|
263
|
+
return pending;
|
|
264
|
+
};
|
|
265
|
+
return client;
|
|
266
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -53,14 +53,25 @@ import {
|
|
|
53
53
|
import { type CedarPolicySource, policyAt } from "./cedar.ts";
|
|
54
54
|
import type { Policy } from "./policy.ts";
|
|
55
55
|
import { type Resource, resourceTypesOf, withoutWriteOnly } from "./resource.ts";
|
|
56
|
-
import {
|
|
56
|
+
import { CODE_MAIL, loginCodeWorkflow } from "./login-code.ts";
|
|
57
|
+
import {
|
|
58
|
+
APPLY_WORKFLOW,
|
|
59
|
+
BUILT_IN_WORKFLOWS,
|
|
60
|
+
CALL_WORKFLOW,
|
|
61
|
+
CHANGE_WORKFLOW,
|
|
62
|
+
DATA_VENDOR,
|
|
63
|
+
LOGIN_CODE_WORKFLOW,
|
|
64
|
+
} from "./shared.ts";
|
|
57
65
|
import { fakeSourceDriver, type RemoteHalf, SOURCE_RESOLVE, sourceDriver, sourceOps } from "./source.ts";
|
|
58
66
|
import { appsListDriver, appsListOps } from "./apps-list.ts";
|
|
67
|
+
import { runtimeDriver, sandboxRuntimeDriver, sanoma } from "./runtime-ops.ts";
|
|
68
|
+
import { tokenStore } from "./tokens.ts";
|
|
59
69
|
import type { Steps } from "./steps.ts";
|
|
60
70
|
import { checkSchedule } from "./schedules.ts";
|
|
61
71
|
import { changeCodeOf, computeVersion } from "./version.ts";
|
|
62
|
-
import type { DeclaredResource } from "./resources.ts";
|
|
63
|
-
import { checkWakeupOps, type DeclaredWakeup } from "./wakeup.ts";
|
|
72
|
+
import type { DeclaredResource, ResourceProblem } from "./resources.ts";
|
|
73
|
+
import { checkWakeupOps, type DeclaredWakeup, type EnvWakeup, HOST_URL } from "./wakeup.ts";
|
|
74
|
+
import { instanceUrl, WAKEUP_TOKEN } from "./wakeup-service.ts";
|
|
64
75
|
import { sanomaWakeupDriver, wakeupSteps } from "./wakeup-driver.ts";
|
|
65
76
|
import { wakeupOps } from "./wakeup-type.ts";
|
|
66
77
|
import { checkDatabaseOps } from "./database.ts";
|
|
@@ -131,6 +142,13 @@ export interface SanomaConfig {
|
|
|
131
142
|
* that sets it and declares a wakeup of its own.
|
|
132
143
|
*/
|
|
133
144
|
wakeup?: true;
|
|
145
|
+
/**
|
|
146
|
+
* How the staff and visitor logins send a sign-in code: `sender`, an operation of a connector in
|
|
147
|
+
* `connectors` with the effect `send` that takes `{ to, subject, text }`, such as
|
|
148
|
+
* `resend.emails.send`. With it, the runtime registers the built-in `login-code` workflow,
|
|
149
|
+
* which the runtime's agent `login` runs; without it, nobody signs in by code.
|
|
150
|
+
*/
|
|
151
|
+
login?: { sender: Op };
|
|
134
152
|
}
|
|
135
153
|
|
|
136
154
|
/** A config, checked, with everything a worker, client or app derives from it. */
|
|
@@ -150,9 +168,12 @@ export interface ResolvedConfig {
|
|
|
150
168
|
changeCode: string;
|
|
151
169
|
/** The DBOS queue runs are started on: `sanoma:<appName>`. */
|
|
152
170
|
queueName: string;
|
|
171
|
+
/** The config's `login`, checked (`checkSender`); the built-in `login-code` workflow sends through its `sender`. */
|
|
172
|
+
login?: { sender: Op };
|
|
153
173
|
/**
|
|
154
174
|
* The operations the connectors declare, by id, the built-in `sanoma.change.*` (`changeOps`, in
|
|
155
|
-
* every config)
|
|
175
|
+
* every config) and the runtime's own a workflow may use (`sanoma`, runtime-ops.ts, in every
|
|
176
|
+
* config), `sanoma.source.resolve` and `sanoma.wakeup`'s when one declares a host type,
|
|
156
177
|
* and the collections' (`data.<name>.<op>`).
|
|
157
178
|
*/
|
|
158
179
|
ops: Map<string, Op>;
|
|
@@ -177,6 +198,11 @@ export interface ResolvedConfig {
|
|
|
177
198
|
* declares a host type.
|
|
178
199
|
*/
|
|
179
200
|
hosts(): Promise<string[]>;
|
|
201
|
+
/**
|
|
202
|
+
* The objects the data files under `root` declare, read at each call: what a workflow's change
|
|
203
|
+
* operation may not aim at, since only apply changes a declared object.
|
|
204
|
+
*/
|
|
205
|
+
declaredObjects(): Promise<DeclaredObjects>;
|
|
180
206
|
/**
|
|
181
207
|
* `root`, as a `file:` URL, when `scenarios/` or an app's `apps/<app>/scenarios/` in it is a
|
|
182
208
|
* directory: the feature files under those, each named by its path from the root.
|
|
@@ -310,12 +336,14 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
|
|
|
310
336
|
const declared = [...collections.values()];
|
|
311
337
|
const dataStore = declared.length ? lazyPool(databaseUrl, "data") : undefined;
|
|
312
338
|
// Built-in, in every config: a change's operations, which the template's policy names
|
|
313
|
-
// (`sanoma.change.merge`),
|
|
339
|
+
// (`sanoma.change.merge`), the listing of apps a page may call (`sanoma.apps.list`), and what a
|
|
340
|
+
// workflow asks the runtime to do for it (`sanoma`, runtime-ops.ts).
|
|
314
341
|
const ops = indexConnectors([
|
|
315
342
|
...running,
|
|
316
343
|
changeOps,
|
|
317
344
|
connectionOps,
|
|
318
345
|
appsListOps,
|
|
346
|
+
sanoma,
|
|
319
347
|
...(hostTyped ? [sourceOps] : []),
|
|
320
348
|
...(dataStore ? [collectionsConnector(declared)] : []),
|
|
321
349
|
]);
|
|
@@ -329,6 +357,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
|
|
|
329
357
|
changeDriver(remotes),
|
|
330
358
|
connectionDriver(connections),
|
|
331
359
|
appsListDriver,
|
|
360
|
+
runtimeDriver({ tokens: tokenStore(() => connectionPool.db()) }),
|
|
332
361
|
...(hostTyped ? [sourceDriver] : []),
|
|
333
362
|
...(dataStore ? [collectionsDriver(declared, dataStore)] : []),
|
|
334
363
|
],
|
|
@@ -342,7 +371,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
|
|
|
342
371
|
if (BUILT_IN_WORKFLOWS.has(wf.name)) {
|
|
343
372
|
throw new Error(`A workflow is named "${wf.name}", the name of a built-in workflow; name it otherwise`);
|
|
344
373
|
}
|
|
345
|
-
checkUses(wf, ops, drivers
|
|
374
|
+
checkUses(wf, ops, drivers);
|
|
346
375
|
checkSchedule(wf);
|
|
347
376
|
const other = names.get(wf.name);
|
|
348
377
|
if (other && other !== wf) {
|
|
@@ -368,9 +397,13 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
|
|
|
368
397
|
const hosts = async () =>
|
|
369
398
|
(await read()).resources.filter((r) => types.get(`${r.vendor}.${r.type}`)?.host).map((r) => r.id);
|
|
370
399
|
names.set(CHANGE_WORKFLOW, changeWorkflow(changeOps, changeSteps({ remotes, hosts })));
|
|
400
|
+
const declaredObjects = () => readDeclaredObjects(root, connectors, declarable);
|
|
371
401
|
// Built-in: a direct call the policy holds for an approval, made a run, when any operation may be called directly.
|
|
372
402
|
const call = callWorkflow(ops, drivers, types);
|
|
373
403
|
if (call.uses.length) names.set(CALL_WORKFLOW, call);
|
|
404
|
+
// Built-in: sending a sign-in code, when the config names how.
|
|
405
|
+
const login = config.login && { sender: checkSender(config.login, ops, drivers) };
|
|
406
|
+
if (login) names.set(LOGIN_CODE_WORKFLOW, loginCodeWorkflow(login.sender, appName));
|
|
374
407
|
const { policy, policies, problems: policyProblems } = policyAt(root, { ops, workflows: names, collections });
|
|
375
408
|
return {
|
|
376
409
|
secrets: config.secrets ?? {},
|
|
@@ -382,6 +415,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
|
|
|
382
415
|
version: computeVersion({ appName, workflows: [...names.values()] }),
|
|
383
416
|
changeCode: changeCodeOf(names.get(CHANGE_WORKFLOW)!),
|
|
384
417
|
queueName: `sanoma:${appName}`,
|
|
418
|
+
...(login && { login }),
|
|
385
419
|
ops,
|
|
386
420
|
drivers,
|
|
387
421
|
driversByVendor,
|
|
@@ -390,6 +424,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
|
|
|
390
424
|
models: makersOf(config.connectors, "model"),
|
|
391
425
|
remotes,
|
|
392
426
|
hosts,
|
|
427
|
+
declaredObjects,
|
|
393
428
|
...(scenarios && { scenarios }),
|
|
394
429
|
types,
|
|
395
430
|
steps: [...new Set([...running.flatMap((c) => setupOf(c).steps ?? []), ...(config.steps ?? [])])],
|
|
@@ -404,6 +439,26 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
|
|
|
404
439
|
};
|
|
405
440
|
}
|
|
406
441
|
|
|
442
|
+
/**
|
|
443
|
+
* The config's `login.sender`, as the config's operations have it: refused unless it is an
|
|
444
|
+
* operation of a connector in `connectors`, with the effect `send`, a driver, and an input that
|
|
445
|
+
* takes `{ to, subject, text }`.
|
|
446
|
+
*/
|
|
447
|
+
function checkSender(login: unknown, ops: ReadonlyMap<string, Op>, drivers: ReadonlyMap<string, unknown>): Op {
|
|
448
|
+
const sender: unknown = (login as { sender?: unknown } | null)?.sender;
|
|
449
|
+
const fix = "name a connector's send operation, such as `login: { sender: resend.emails.send }`";
|
|
450
|
+
if (!isOp(sender)) throw new Error(`The config's \`login.sender\` is not an operation: ${fix}`);
|
|
451
|
+
const op = ops.get(sender.id);
|
|
452
|
+
if (!op) throw new Error(`The config's \`login.sender\`, ${sender.id}, is of no connector in \`connectors\`: add it`);
|
|
453
|
+
if (op.effect !== "send")
|
|
454
|
+
throw new Error(`The config's \`login.sender\`, ${op.id}, does not send (its effect is ${op.effect}): ${fix}`);
|
|
455
|
+
if (!drivers.has(op.id))
|
|
456
|
+
throw new Error(`The config's \`login.sender\`, ${op.id}, has no driver: the connector package must attach one`);
|
|
457
|
+
if (!op.input.safeParse(CODE_MAIL).success)
|
|
458
|
+
throw new Error(`The config's \`login.sender\`, ${op.id}, does not take an email as { to, subject, text }: ${fix}`);
|
|
459
|
+
return op;
|
|
460
|
+
}
|
|
461
|
+
|
|
407
462
|
/**
|
|
408
463
|
* The config's collections by name, checked: each one `collection()` made, no two with one name,
|
|
409
464
|
* and no connector of their vendor, `DATA_VENDOR`.
|
|
@@ -431,11 +486,13 @@ function collectionsOf(config: SanomaConfig): Map<string, Collection> {
|
|
|
431
486
|
/**
|
|
432
487
|
* The built-in drivers a sandbox run calls beside its fakes (`seedSandbox`), for the built-in
|
|
433
488
|
* operations among `ops` that no fake implements: `sanoma.apps.list`'s own, which needs no
|
|
434
|
-
* credentials and changes nothing (GitHub's public search),
|
|
435
|
-
* (`
|
|
489
|
+
* credentials and changes nothing (GitHub's public search), the runtime's own operations over the
|
|
490
|
+
* run's secrets and tokens of its own (`sandboxRuntimeDriver`, made for each run), and
|
|
491
|
+
* `sanoma.source.resolve`'s fake (`fakeSourceDriver`), for a config with a host type.
|
|
436
492
|
*/
|
|
437
493
|
export const sandboxDrivers = (ops: ReadonlyMap<string, unknown>): Driver[] => [
|
|
438
494
|
appsListDriver,
|
|
495
|
+
sandboxRuntimeDriver(),
|
|
439
496
|
...(ops.has(SOURCE_RESOLVE) ? [fakeSourceDriver] : []),
|
|
440
497
|
];
|
|
441
498
|
|
|
@@ -545,6 +602,47 @@ export async function declaredWakeup(config: SanomaConfig): Promise<DeclaredWake
|
|
|
545
602
|
};
|
|
546
603
|
}
|
|
547
604
|
|
|
605
|
+
/**
|
|
606
|
+
* The wakeup `env` names (`EnvWakeup`): its host's URL, `SANOMA_HOST_URL`, as the wakeup service
|
|
607
|
+
* keeps one (`instanceUrl`), and its token, `SANOMA_WAKEUP_TOKEN`, when both are set and the URL is
|
|
608
|
+
* one; undefined otherwise. A host Sanoma Cloud made gives both to an instance that declares no
|
|
609
|
+
* wakeup of its own.
|
|
610
|
+
*/
|
|
611
|
+
export function envWakeup(env: NodeJS.ProcessEnv): EnvWakeup | undefined {
|
|
612
|
+
const host = env[HOST_URL]?.trim();
|
|
613
|
+
const token = env[WAKEUP_TOKEN]?.trim();
|
|
614
|
+
const checked = host ? instanceUrl(host) : undefined;
|
|
615
|
+
return checked && "url" in checked && token ? { url: checked.url, token } : undefined;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* The objects a company's data files declare, which only apply changes (`refuseDeclared` in
|
|
620
|
+
* `call.ts`), by `<vendor>.<type> <id or name>` to their key: each binding in the lockfile, an
|
|
621
|
+
* orphan's too, by its id, and each declaration by its identity (what its type's `find` made);
|
|
622
|
+
* and the files' problems, while which the declared objects are not known.
|
|
623
|
+
*/
|
|
624
|
+
export interface DeclaredObjects {
|
|
625
|
+
keys: ReadonlyMap<string, string>;
|
|
626
|
+
problems: readonly ResourceProblem[];
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** The objects the data files under `root` and its lockfile declare (`DeclaredObjects`): none without a root. */
|
|
630
|
+
async function readDeclaredObjects(
|
|
631
|
+
root: string | undefined,
|
|
632
|
+
connectors: readonly Connector<any, any>[],
|
|
633
|
+
declarable: ReadonlyMap<string, Resource>,
|
|
634
|
+
): Promise<DeclaredObjects> {
|
|
635
|
+
if (root === undefined) return { keys: new Map(), problems: [] };
|
|
636
|
+
// Loaded when a call asks, not with the worker.
|
|
637
|
+
const { loadDataFiles } = await import("./resources.ts");
|
|
638
|
+
const { resources, bindings, problems } = loadDataFiles(root, connectors, declarable);
|
|
639
|
+
const named = resources.flatMap((r) =>
|
|
640
|
+
r.identity === undefined ? [] : [[`${r.vendor}.${r.type} ${r.identity}`, r.id] as const],
|
|
641
|
+
);
|
|
642
|
+
const bound = Object.entries(bindings).map(([key, { type, id }]) => [`${type} ${id}`, key] as const);
|
|
643
|
+
return { keys: new Map([...named, ...bound]), problems };
|
|
644
|
+
}
|
|
645
|
+
|
|
548
646
|
/**
|
|
549
647
|
* The data files under `root` and the lockfile beside them, in one read, as a drift or apply run
|
|
550
648
|
* records them: each resource with its binding, without its write-only values (a secret marker
|
|
@@ -693,19 +791,15 @@ function guarded(vendor: string, env: DriverEnv, fn: DriverFn): DriverFn {
|
|
|
693
791
|
* Every operation a workflow uses must be one the config's connectors declare, with a driver.
|
|
694
792
|
* The workflow normally imports the same connector object; a copy is accepted only if it
|
|
695
793
|
* declares the same effect and retry setting, and the worker's declaration is used either way.
|
|
696
|
-
* None may use an operation a built-in workflow alone calls (`reservedFor`): a
|
|
697
|
-
*
|
|
794
|
+
* None may use an operation a built-in workflow alone calls (`reservedFor`): a change's step, which
|
|
795
|
+
* only the change workflow takes. A resource type's change operations are allowed: the call
|
|
796
|
+
* refuses one aimed at a declared object (`DeclaredObjects`), which only apply changes.
|
|
698
797
|
*/
|
|
699
|
-
function checkUses(
|
|
700
|
-
wf: WorkflowDefinition<any, any>,
|
|
701
|
-
ops: Map<string, Op>,
|
|
702
|
-
impls: Map<string, unknown>,
|
|
703
|
-
types: ReadonlyMap<string, Resource>,
|
|
704
|
-
) {
|
|
798
|
+
function checkUses(wf: WorkflowDefinition<any, any>, ops: Map<string, Op>, impls: Map<string, unknown>) {
|
|
705
799
|
const problems: string[] = [];
|
|
706
800
|
for (const op of (wf.uses as readonly Use[]).filter(isOp)) {
|
|
707
801
|
const known = ops.get(op.id);
|
|
708
|
-
const reserved = known && reservedFor(known
|
|
802
|
+
const reserved = known && reservedFor(known);
|
|
709
803
|
if (!known) problems.push(`${op.id} is not declared by any connector in \`connectors\``);
|
|
710
804
|
else if (reserved) {
|
|
711
805
|
problems.push(`${op.id} ${reserved.does}, which only ${reserved.workflow} does: ${reserved.instead} instead`);
|
package/src/connection.ts
CHANGED
|
@@ -565,7 +565,10 @@ export class ConnectionRegistry {
|
|
|
565
565
|
const method = this.catalog.methods.get(row.vendor)![row.method]!;
|
|
566
566
|
const bundle = row.credential ? await this.secrets?.read(row.credential) : undefined;
|
|
567
567
|
if (row.credential && !bundle) throw new SecretStoreError("unavailable");
|
|
568
|
-
const identity = await verifyAuthentication(method, bundle && decodeCredentials(method, bundle.value)
|
|
568
|
+
const identity = await verifyAuthentication(method, bundle && decodeCredentials(method, bundle.value), {
|
|
569
|
+
connectionId: row.id,
|
|
570
|
+
vendor: row.vendor,
|
|
571
|
+
});
|
|
569
572
|
if (identity.account !== row.account) throw new AuthenticationError("wrong_account");
|
|
570
573
|
if (JSON.stringify(identity.scopes) !== JSON.stringify([...new Set(row.scopes)].toSorted()))
|
|
571
574
|
throw new AuthenticationError("scope_changed");
|
|
@@ -670,7 +673,10 @@ export class ConnectionRegistry {
|
|
|
670
673
|
material.credentials = decodeCredentials(method, bundle.value);
|
|
671
674
|
material.credentialVersion = bundle.version;
|
|
672
675
|
}
|
|
673
|
-
const identity = await verifyAuthentication(method, material.credentials
|
|
676
|
+
const identity = await verifyAuthentication(method, material.credentials, {
|
|
677
|
+
connectionId: selected.id,
|
|
678
|
+
vendor: selected.vendor,
|
|
679
|
+
});
|
|
674
680
|
if (identity.account !== selected.account) throw new AuthenticationError("wrong_account");
|
|
675
681
|
if (JSON.stringify(identity.scopes) !== JSON.stringify([...new Set(selected.scopes)].toSorted()))
|
|
676
682
|
throw new AuthenticationError("scope_changed");
|
package/src/db.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { warn } from "./log.ts";
|
|
|
5
5
|
import { errorMessage } from "./shared.ts";
|
|
6
6
|
|
|
7
7
|
// The runtime's own tables live in the config's database, beside DBOS's: the credentials, the
|
|
8
|
-
// ledger, the collections' records, the wakeup's row
|
|
8
|
+
// ledger, the collections' records, the wakeup's row, the operator agent's chats and the api tokens. What they
|
|
9
9
|
// share is here: the one preparation of a database, a pool that opens once it is prepared and
|
|
10
10
|
// survives a dropped connection, and a table creation two processes may race.
|
|
11
11
|
|
|
@@ -28,6 +28,8 @@ export const WAKEUP_TABLE = "sanoma_wakeup";
|
|
|
28
28
|
export const CHATS_TABLE = "sanoma_chats";
|
|
29
29
|
/** Each chat's messages, the AI SDK's UI messages as JSON, in `seq` order. */
|
|
30
30
|
export const CHAT_MESSAGES_TABLE = "sanoma_chat_messages";
|
|
31
|
+
/** The api tokens (`tokens.ts`): each one's SHA-256, never the token, whom it acts for, its groups, and when it was revoked. */
|
|
32
|
+
export const API_TOKENS_TABLE = "sanoma_api_tokens";
|
|
31
33
|
|
|
32
34
|
/** The runtime's tables, each with the word the messages name it by. */
|
|
33
35
|
const TABLES: readonly [what: string, ddl: string][] = [
|
|
@@ -97,6 +99,11 @@ const TABLES: readonly [what: string, ddl: string][] = [
|
|
|
97
99
|
`CREATE TABLE IF NOT EXISTS ${CHAT_MESSAGES_TABLE} (chat_id text NOT NULL REFERENCES ${CHATS_TABLE} (id) ` +
|
|
98
100
|
"ON DELETE CASCADE, id text NOT NULL, seq integer NOT NULL, message jsonb NOT NULL, PRIMARY KEY (chat_id, id))",
|
|
99
101
|
],
|
|
102
|
+
[
|
|
103
|
+
"api tokens",
|
|
104
|
+
`CREATE TABLE IF NOT EXISTS ${API_TOKENS_TABLE} (id text PRIMARY KEY, hash text NOT NULL UNIQUE, ` +
|
|
105
|
+
"subject text NOT NULL, groups jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), revoked_at timestamptz)",
|
|
106
|
+
],
|
|
100
107
|
];
|
|
101
108
|
|
|
102
109
|
/** The Postgres error code of `err`, or an empty string when it has none. */
|