@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,271 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import type { protos } from "@google-cloud/kms";
|
|
3
|
+
import crc32c from "crc-32/crc32c.js";
|
|
4
|
+
import type { Db } from "./db.ts";
|
|
5
|
+
import { SecretStoreError, type SecretAddress, type SecretStore, type SecretValue } from "./secret-store.ts";
|
|
6
|
+
|
|
7
|
+
/** Install with a privileged connection; runtime needs only SELECT/INSERT/UPDATE/DELETE on its company database. */
|
|
8
|
+
export const KMS_SECRETS_SQL = `
|
|
9
|
+
CREATE SCHEMA IF NOT EXISTS sanoma_kms;
|
|
10
|
+
REVOKE ALL ON SCHEMA sanoma_kms FROM PUBLIC;
|
|
11
|
+
CREATE TABLE IF NOT EXISTS sanoma_kms.secrets (
|
|
12
|
+
company text NOT NULL, namespace text NOT NULL, path text NOT NULL,
|
|
13
|
+
version uuid NOT NULL, envelope jsonb NOT NULL,
|
|
14
|
+
updated_by text NOT NULL, updated_at timestamptz NOT NULL DEFAULT now(),
|
|
15
|
+
PRIMARY KEY (company, namespace, path)
|
|
16
|
+
);
|
|
17
|
+
REVOKE ALL ON sanoma_kms.secrets FROM PUBLIC;
|
|
18
|
+
DO $revoke$
|
|
19
|
+
DECLARE api_role text;
|
|
20
|
+
BEGIN
|
|
21
|
+
FOREACH api_role IN ARRAY ARRAY['anon', 'authenticated', 'service_role'] LOOP
|
|
22
|
+
IF EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = api_role) THEN
|
|
23
|
+
EXECUTE format('REVOKE ALL ON SCHEMA sanoma_kms FROM %I', api_role);
|
|
24
|
+
EXECUTE format('REVOKE ALL ON sanoma_kms.secrets FROM %I', api_role);
|
|
25
|
+
END IF;
|
|
26
|
+
END LOOP;
|
|
27
|
+
END;
|
|
28
|
+
$revoke$;
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
/** Promise interface implemented by Google's KeyManagementServiceClient. */
|
|
32
|
+
export interface GcpKmsClient {
|
|
33
|
+
encrypt(
|
|
34
|
+
request: protos.google.cloud.kms.v1.IEncryptRequest,
|
|
35
|
+
): Promise<[protos.google.cloud.kms.v1.IEncryptResponse, ...unknown[]]>;
|
|
36
|
+
decrypt(
|
|
37
|
+
request: protos.google.cloud.kms.v1.IDecryptRequest,
|
|
38
|
+
): Promise<[protos.google.cloud.kms.v1.IDecryptResponse, ...unknown[]]>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface KmsPostgresOptions {
|
|
42
|
+
/** Fixed by trusted deployment bootstrap. IAM must independently restrict the runtime to this company’s CryptoKeys. */
|
|
43
|
+
company: string;
|
|
44
|
+
/** Full GCP CryptoKey resource name for new writes, using its primary version. */
|
|
45
|
+
keyName: string;
|
|
46
|
+
/** Retained CryptoKey names allowed for reads during key replacement/backup recovery. */
|
|
47
|
+
decryptKeyNames?: readonly string[];
|
|
48
|
+
/** Explicit client under a company-scoped IAM identity, never fleet credentials. Caller owns its lifecycle. */
|
|
49
|
+
kms: GcpKmsClient;
|
|
50
|
+
/** Company-scoped database access. Caller owns connection lifecycle; never shared admin authority. */
|
|
51
|
+
database: () => Promise<Db>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface Envelope {
|
|
55
|
+
format: "sanoma-gcp-kms-v1";
|
|
56
|
+
key: string;
|
|
57
|
+
keyVersion: string;
|
|
58
|
+
wrapped: string;
|
|
59
|
+
nonce: string;
|
|
60
|
+
ciphertext: string;
|
|
61
|
+
tag: string;
|
|
62
|
+
}
|
|
63
|
+
interface Row {
|
|
64
|
+
version: string;
|
|
65
|
+
envelope: Envelope;
|
|
66
|
+
updated_by: string;
|
|
67
|
+
updated_at: Date;
|
|
68
|
+
}
|
|
69
|
+
const FORMAT = "sanoma-gcp-kms-v1";
|
|
70
|
+
const MAX_BYTES = 65_536;
|
|
71
|
+
const keyName =
|
|
72
|
+
/^projects\/[a-zA-Z0-9-]+\/locations\/[a-z0-9-]+\/keyRings\/[a-zA-Z0-9_-]+\/cryptoKeys\/[a-zA-Z0-9_-]+$/;
|
|
73
|
+
const nonempty = (v: string) =>
|
|
74
|
+
typeof v === "string" && v.length > 0 && Buffer.byteLength(v, "utf8") <= 1024 && !v.includes("\0");
|
|
75
|
+
const failed = (error: unknown): never => {
|
|
76
|
+
if (error instanceof SecretStoreError) throw error;
|
|
77
|
+
const code = (error as { code?: string | number } | null)?.code;
|
|
78
|
+
throw new SecretStoreError(
|
|
79
|
+
code === 7 || code === 16 || code === "42501"
|
|
80
|
+
? "denied"
|
|
81
|
+
: code === 3
|
|
82
|
+
? "invalid_value"
|
|
83
|
+
: code === 5 || code === 9 || code === "42P01"
|
|
84
|
+
? "invalid_configuration"
|
|
85
|
+
: "unavailable",
|
|
86
|
+
);
|
|
87
|
+
};
|
|
88
|
+
const safe = async <T>(run: () => Promise<T>): Promise<T> => {
|
|
89
|
+
try {
|
|
90
|
+
return await run();
|
|
91
|
+
} catch (e) {
|
|
92
|
+
return failed(e);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
const bytes = (v: string, length?: number, max = MAX_BYTES): Buffer => {
|
|
96
|
+
if (
|
|
97
|
+
typeof v !== "string" ||
|
|
98
|
+
v.length > Math.ceil((length ?? max) / 3) * 4 ||
|
|
99
|
+
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(v)
|
|
100
|
+
)
|
|
101
|
+
throw new SecretStoreError("invalid_value");
|
|
102
|
+
const b = Buffer.from(v, "base64");
|
|
103
|
+
if (length !== undefined && b.length !== length) throw new SecretStoreError("invalid_value");
|
|
104
|
+
return b;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const versionOf = (name: unknown, key: string): name is string =>
|
|
108
|
+
typeof name === "string" &&
|
|
109
|
+
name.startsWith(`${key}/cryptoKeyVersions/`) &&
|
|
110
|
+
/^[1-9][0-9]{0,19}$/.test(name.slice(`${key}/cryptoKeyVersions/`.length));
|
|
111
|
+
const checksum = (value: Uint8Array) => crc32c.buf(value) >>> 0;
|
|
112
|
+
|
|
113
|
+
/** Versioned envelope encryption; no plaintext/data-key cache, no SANOMA_SECRETS_KEY and no Secrets Manager. */
|
|
114
|
+
export function kmsPostgresStore(options: KmsPostgresOptions): SecretStore {
|
|
115
|
+
const { company, keyName: writeKey, kms, database } = options;
|
|
116
|
+
const keys = new Set([writeKey, ...(options.decryptKeyNames ?? [])]);
|
|
117
|
+
if (!nonempty(company) || [...keys].some((k) => !nonempty(k) || !keyName.test(k)))
|
|
118
|
+
throw new SecretStoreError("invalid_configuration");
|
|
119
|
+
const context = (address: SecretAddress, version: string) => {
|
|
120
|
+
if (!nonempty(address.namespace) || !nonempty(address.path)) throw new SecretStoreError("invalid_configuration");
|
|
121
|
+
return { company, namespace: address.namespace, path: address.path, version, format: FORMAT };
|
|
122
|
+
};
|
|
123
|
+
const seal = async (address: SecretAddress, value: string, version: string): Promise<Envelope> => {
|
|
124
|
+
const bound = context(address, version);
|
|
125
|
+
const aad = Buffer.from(JSON.stringify(bound));
|
|
126
|
+
const dataKey = randomBytes(32);
|
|
127
|
+
try {
|
|
128
|
+
const [encrypted] = await kms.encrypt({
|
|
129
|
+
name: writeKey,
|
|
130
|
+
plaintext: dataKey,
|
|
131
|
+
plaintextCrc32c: { value: checksum(dataKey) },
|
|
132
|
+
additionalAuthenticatedData: aad,
|
|
133
|
+
additionalAuthenticatedDataCrc32c: { value: checksum(aad) },
|
|
134
|
+
});
|
|
135
|
+
const wrapped =
|
|
136
|
+
typeof encrypted.ciphertext === "string" ? bytes(encrypted.ciphertext, undefined, 6144) : encrypted.ciphertext;
|
|
137
|
+
if (
|
|
138
|
+
!versionOf(encrypted.name, writeKey) ||
|
|
139
|
+
!wrapped?.length ||
|
|
140
|
+
wrapped.length > 6144 ||
|
|
141
|
+
!encrypted.verifiedPlaintextCrc32c ||
|
|
142
|
+
!encrypted.verifiedAdditionalAuthenticatedDataCrc32c ||
|
|
143
|
+
Number(encrypted.ciphertextCrc32c?.value ?? NaN) !== checksum(wrapped)
|
|
144
|
+
)
|
|
145
|
+
throw new SecretStoreError("invalid_value");
|
|
146
|
+
const nonce = randomBytes(12);
|
|
147
|
+
const cipher = createCipheriv("aes-256-gcm", dataKey, nonce).setAAD(
|
|
148
|
+
Buffer.from(JSON.stringify({ ...bound, keyVersion: encrypted.name })),
|
|
149
|
+
);
|
|
150
|
+
const ciphertext = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
|
|
151
|
+
return {
|
|
152
|
+
format: FORMAT,
|
|
153
|
+
key: writeKey,
|
|
154
|
+
keyVersion: encrypted.name,
|
|
155
|
+
wrapped: Buffer.from(wrapped).toString("base64"),
|
|
156
|
+
nonce: nonce.toString("base64"),
|
|
157
|
+
ciphertext: ciphertext.toString("base64"),
|
|
158
|
+
tag: cipher.getAuthTag().toString("base64"),
|
|
159
|
+
};
|
|
160
|
+
} finally {
|
|
161
|
+
dataKey.fill(0);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
const open = async (address: SecretAddress, row: Row): Promise<SecretValue> => {
|
|
165
|
+
const e = row.envelope;
|
|
166
|
+
if (!e || e.format !== FORMAT || !keys.has(e.key) || !versionOf(e.keyVersion, e.key))
|
|
167
|
+
throw new SecretStoreError("invalid_value");
|
|
168
|
+
const nonce = bytes(e.nonce, 12),
|
|
169
|
+
tag = bytes(e.tag, 16),
|
|
170
|
+
ciphertext = bytes(e.ciphertext),
|
|
171
|
+
wrapped = bytes(e.wrapped, undefined, 6144);
|
|
172
|
+
if (ciphertext.length > MAX_BYTES || !wrapped.length || wrapped.length > 6144)
|
|
173
|
+
throw new SecretStoreError("invalid_value");
|
|
174
|
+
const bound = context(address, row.version);
|
|
175
|
+
const aad = Buffer.from(JSON.stringify(bound));
|
|
176
|
+
const [decrypted] = await kms.decrypt({
|
|
177
|
+
name: e.key,
|
|
178
|
+
ciphertext: wrapped,
|
|
179
|
+
ciphertextCrc32c: { value: checksum(wrapped) },
|
|
180
|
+
additionalAuthenticatedData: aad,
|
|
181
|
+
additionalAuthenticatedDataCrc32c: { value: checksum(aad) },
|
|
182
|
+
});
|
|
183
|
+
const dataKey = typeof decrypted.plaintext === "string" ? bytes(decrypted.plaintext, 32) : decrypted.plaintext;
|
|
184
|
+
try {
|
|
185
|
+
if (dataKey?.length !== 32 || Number(decrypted.plaintextCrc32c?.value ?? NaN) !== checksum(dataKey))
|
|
186
|
+
throw new SecretStoreError("invalid_value");
|
|
187
|
+
const decipher = createDecipheriv("aes-256-gcm", dataKey, nonce)
|
|
188
|
+
.setAAD(Buffer.from(JSON.stringify({ ...bound, keyVersion: e.keyVersion })))
|
|
189
|
+
.setAuthTag(tag);
|
|
190
|
+
let plaintext: Buffer;
|
|
191
|
+
try {
|
|
192
|
+
plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
193
|
+
} catch {
|
|
194
|
+
throw new SecretStoreError("invalid_value");
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
return {
|
|
198
|
+
value: plaintext.toString("utf8"),
|
|
199
|
+
version: row.version,
|
|
200
|
+
updatedBy: row.updated_by,
|
|
201
|
+
updatedAt: row.updated_at.toISOString(),
|
|
202
|
+
};
|
|
203
|
+
} finally {
|
|
204
|
+
plaintext.fill(0);
|
|
205
|
+
}
|
|
206
|
+
} finally {
|
|
207
|
+
dataKey?.fill(0);
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
return {
|
|
211
|
+
id: "kms-postgres",
|
|
212
|
+
capabilities: Object.freeze({
|
|
213
|
+
writable: true,
|
|
214
|
+
maxBytes: MAX_BYTES,
|
|
215
|
+
conditionalWrites: "atomic",
|
|
216
|
+
deletion: "destroy",
|
|
217
|
+
changeDetection: "read",
|
|
218
|
+
}),
|
|
219
|
+
validate: (namespace) =>
|
|
220
|
+
safe(async () => {
|
|
221
|
+
const address = { namespace, path: "__validation__" },
|
|
222
|
+
version = randomUUID();
|
|
223
|
+
const envelope = await seal(address, "validation", version);
|
|
224
|
+
await open(address, { envelope, version, updated_by: "validation", updated_at: new Date() });
|
|
225
|
+
await (await database()).query("SELECT version FROM sanoma_kms.secrets WHERE company=$1 LIMIT 0", [company]);
|
|
226
|
+
// Own-key usability only. Cross-company authorization requires live IAM proof with another existing key.
|
|
227
|
+
}),
|
|
228
|
+
read: (address) =>
|
|
229
|
+
safe(async () => {
|
|
230
|
+
context(address, "read");
|
|
231
|
+
const { rows } = await (
|
|
232
|
+
await database()
|
|
233
|
+
).query<Row>(
|
|
234
|
+
"SELECT version,envelope,updated_by,updated_at FROM sanoma_kms.secrets WHERE company=$1 AND namespace=$2 AND path=$3",
|
|
235
|
+
[company, address.namespace, address.path],
|
|
236
|
+
);
|
|
237
|
+
return rows[0] && open(address, rows[0]);
|
|
238
|
+
}),
|
|
239
|
+
write: (address, value, { expectedVersion, by }) =>
|
|
240
|
+
safe(async () => {
|
|
241
|
+
if (Buffer.byteLength(value, "utf8") > MAX_BYTES) throw new SecretStoreError("too_large");
|
|
242
|
+
if (!nonempty(by)) throw new SecretStoreError("invalid_value");
|
|
243
|
+
const version = randomUUID(),
|
|
244
|
+
envelope = await seal(address, value, version);
|
|
245
|
+
const args = [company, address.namespace, address.path, version, JSON.stringify(envelope), by];
|
|
246
|
+
const sql =
|
|
247
|
+
expectedVersion === null
|
|
248
|
+
? "INSERT INTO sanoma_kms.secrets(company,namespace,path,version,envelope,updated_by) VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT(company,namespace,path) DO NOTHING RETURNING version,envelope,updated_by,updated_at"
|
|
249
|
+
: "UPDATE sanoma_kms.secrets SET version=$4,envelope=$5,updated_by=$6,updated_at=now() WHERE company=$1 AND namespace=$2 AND path=$3 AND version::text=$7 RETURNING version,envelope,updated_by,updated_at";
|
|
250
|
+
const { rows } = await (
|
|
251
|
+
await database()
|
|
252
|
+
).query<Row>(sql, expectedVersion === null ? args : [...args, expectedVersion]);
|
|
253
|
+
if (!rows[0]) throw new SecretStoreError("conflict");
|
|
254
|
+
// Return the caller's value, never make a second decrypt after a successful commit.
|
|
255
|
+
return { value, version, updatedBy: by, updatedAt: rows[0].updated_at.toISOString() };
|
|
256
|
+
}),
|
|
257
|
+
delete: (address, { expectedVersion }) =>
|
|
258
|
+
safe(async () => {
|
|
259
|
+
context(address, expectedVersion);
|
|
260
|
+
const { rowCount } = await (
|
|
261
|
+
await database()
|
|
262
|
+
).query("DELETE FROM sanoma_kms.secrets WHERE company=$1 AND namespace=$2 AND path=$3 AND version::text=$4", [
|
|
263
|
+
company,
|
|
264
|
+
address.namespace,
|
|
265
|
+
address.path,
|
|
266
|
+
expectedVersion,
|
|
267
|
+
]);
|
|
268
|
+
if (!rowCount) throw new SecretStoreError("conflict");
|
|
269
|
+
}),
|
|
270
|
+
};
|
|
271
|
+
}
|
package/src/ledger.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ConnectionSnapshot } from "./connection.ts";
|
|
2
2
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
3
4
|
import { appendFile, mkdir, readFile, truncate } from "node:fs/promises";
|
|
4
5
|
import { join, resolve } from "node:path";
|
|
5
6
|
import { setTimeout as delay } from "node:timers/promises";
|
|
@@ -8,7 +9,7 @@ import { type Db, LEDGER_TABLE as TABLE, lazyPool, pgCode } from "./db.ts";
|
|
|
8
9
|
import type { Approver, Principal } from "./define.ts";
|
|
9
10
|
import { type ErrorInfo, isFinal, keepCode } from "./errors.ts";
|
|
10
11
|
import { shown, warn } from "./log.ts";
|
|
11
|
-
import type { Effect } from "./op.ts";
|
|
12
|
+
import type { Effect, SignInMethod } from "./op.ts";
|
|
12
13
|
import type { RecordedDecision } from "./policy.ts";
|
|
13
14
|
import type { Run } from "./run.ts";
|
|
14
15
|
import { errorMessage } from "./shared.ts";
|
|
@@ -137,8 +138,58 @@ export type LedgerRecord = {
|
|
|
137
138
|
| { type: "run.finished"; output: unknown }
|
|
138
139
|
/** `error` as for `op.called`. */
|
|
139
140
|
| { type: "run.failed"; error: ErrorInfo }
|
|
141
|
+
/**
|
|
142
|
+
* A declared person signed in to the staff login, by `method`; written outside any run, in the
|
|
143
|
+
* person's own `signin:<person>` (`signInRecord`), its `at` when.
|
|
144
|
+
*/
|
|
145
|
+
| { type: "signin.recorded"; person: string; method: SignInMethod }
|
|
146
|
+
/** `by` minted an invite for the declared `person`, good until `expiresAt` (ISO 8601); in `invite:<person>`, outside any run. */
|
|
147
|
+
| { type: "invite.minted"; person: string; by: string; expiresAt: string }
|
|
140
148
|
);
|
|
141
149
|
|
|
150
|
+
/** What a sign-in's or an invite's record names as its workflow: the staff login, which is no run. */
|
|
151
|
+
const LOGIN = "login";
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The record of a sign-in or an invite, written outside any run (`SanomaClient.recordSignIn`,
|
|
155
|
+
* `recordInvite`): in the run `signin:<person>` or `invite:<person>`, each with an id of its own,
|
|
156
|
+
* `seq` 0, so a person's records read in the order they were written.
|
|
157
|
+
*/
|
|
158
|
+
export function loginRecord(
|
|
159
|
+
app: string,
|
|
160
|
+
actor: Principal,
|
|
161
|
+
body: Extract<LedgerBody, { type: "signin.recorded" | "invite.minted" }>,
|
|
162
|
+
at = Date.now(),
|
|
163
|
+
): LedgerRecord {
|
|
164
|
+
const runId = `${body.type === "signin.recorded" ? "signin" : "invite"}:${body.person}`;
|
|
165
|
+
return {
|
|
166
|
+
v: 1,
|
|
167
|
+
app,
|
|
168
|
+
id: `${runId}:${body.type}:${at}:${randomUUID()}`,
|
|
169
|
+
runId,
|
|
170
|
+
seq: 0,
|
|
171
|
+
at,
|
|
172
|
+
actor,
|
|
173
|
+
workflow: LOGIN,
|
|
174
|
+
...body,
|
|
175
|
+
} as LedgerRecord;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** When each of `people` last signed in, and how, by id: from their `signin:<person>` records; one who never did is left out. */
|
|
179
|
+
export async function lastSignIns(
|
|
180
|
+
store: LedgerStore,
|
|
181
|
+
people: readonly string[],
|
|
182
|
+
): Promise<Record<string, { at: number; method: SignInMethod }>> {
|
|
183
|
+
const last = await Promise.all(
|
|
184
|
+
people.map(async (person) => {
|
|
185
|
+
const records = await store.read(`signin:${person}`);
|
|
186
|
+
const latest = records.reduce<LedgerRecord | undefined>((a, r) => (a && a.at > r.at ? a : r), undefined);
|
|
187
|
+
return latest?.type === "signin.recorded" ? [[person, { at: latest.at, method: latest.method }] as const] : [];
|
|
188
|
+
}),
|
|
189
|
+
);
|
|
190
|
+
return Object.fromEntries(last.flat());
|
|
191
|
+
}
|
|
192
|
+
|
|
142
193
|
/**
|
|
143
194
|
* The `ctx.all` member a record was written in. `id` is `all:<seq>`, the run's next `seq` when
|
|
144
195
|
* the `ctx.all` began, so a replay names it the same; `index` is the member's position, `size`
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { defineWorkflow, type WorkflowDefinition } from "./define.ts";
|
|
3
|
+
import type { Op } from "./op.ts";
|
|
4
|
+
import { openLoginCode } from "./seal.ts";
|
|
5
|
+
import type { LOGIN_CODE_WORKFLOW } from "./shared.ts";
|
|
6
|
+
|
|
7
|
+
// The built-in `login-code` workflow: one mail with a sign-in code, through the config's
|
|
8
|
+
// `login.sender`. The app starts it as the runtime's agent `login` (`LOGIN_AGENT`) with the code
|
|
9
|
+
// sealed (`sealLoginCode`), so neither the run's input nor the ledger shows it: the call is
|
|
10
|
+
// recorded with its body as `<text>` (`recorded` in `call.ts`). It is a workflow like any other:
|
|
11
|
+
// the call goes through `ctx`, under the policy, in the ledger.
|
|
12
|
+
|
|
13
|
+
/** An operation on `ctx`. */
|
|
14
|
+
type OpCall = (input: unknown) => Promise<unknown>;
|
|
15
|
+
|
|
16
|
+
/** The workflow's input: to whom, and the code, sealed. */
|
|
17
|
+
const LoginCodeInput = z.object({ to: z.string().min(1), code: z.string().min(1) });
|
|
18
|
+
|
|
19
|
+
/** What a sign-in code is sent as, for checks before any is: a sender must take it, and the policy is asked about it. */
|
|
20
|
+
export const CODE_MAIL = { to: "person@example.com", subject: "Your sign-in code", text: "<text>" };
|
|
21
|
+
|
|
22
|
+
/** The mail's text: the code, what it is for, and how long it lasts; nothing else, and no link. */
|
|
23
|
+
const textOf = (company: string, code: string) =>
|
|
24
|
+
`Your sign-in code for ${company} is ${code}.\n\n` +
|
|
25
|
+
"It works once, for five minutes. If you did not ask for it, ignore this mail.\n";
|
|
26
|
+
|
|
27
|
+
/** The login-code workflow for a config: it sends through `sender`, saying it is `company`'s. */
|
|
28
|
+
export function loginCodeWorkflow(
|
|
29
|
+
sender: Op,
|
|
30
|
+
company: string,
|
|
31
|
+
): WorkflowDefinition<readonly Op[], typeof LoginCodeInput> {
|
|
32
|
+
return defineWorkflow({
|
|
33
|
+
// The literal, held to the one name: the outline finds a workflow by a string, or a const of its own file.
|
|
34
|
+
name: "login-code" satisfies typeof LOGIN_CODE_WORKFLOW,
|
|
35
|
+
title: "Send a sign-in code",
|
|
36
|
+
trigger: "manual",
|
|
37
|
+
input: LoginCodeInput,
|
|
38
|
+
uses: [sender],
|
|
39
|
+
run: async (ctx, input) => {
|
|
40
|
+
const { vendor, resource, name } = sender;
|
|
41
|
+
const mail = {
|
|
42
|
+
to: input.to,
|
|
43
|
+
subject: `Your ${company} sign-in code`,
|
|
44
|
+
text: textOf(company, openLoginCode(input.code, input.to)),
|
|
45
|
+
};
|
|
46
|
+
// `ctx` called directly, so the outline places the call.
|
|
47
|
+
return (ctx as unknown as Record<string, Record<string, Record<string, OpCall>>>)[vendor]![resource]![name]!(
|
|
48
|
+
mail,
|
|
49
|
+
);
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
}
|
package/src/op.ts
CHANGED
|
@@ -360,12 +360,31 @@ export interface ConnectorSetup {
|
|
|
360
360
|
/** What the app gives a connector's model half (`ConnectorSetup.model`): the secrets, as a login half reads them. */
|
|
361
361
|
export type ModelContext = Pick<LoginContext, "secrets">;
|
|
362
362
|
|
|
363
|
-
/**
|
|
363
|
+
/**
|
|
364
|
+
* What a login vouches for about who is asking: a name they gave (`id`, the loopback's), a
|
|
365
|
+
* verified email, a GitHub login. The app admits the declared person they match (`personOf`).
|
|
366
|
+
*/
|
|
367
|
+
export interface LoginIdentity {
|
|
368
|
+
id?: string;
|
|
369
|
+
email?: string;
|
|
370
|
+
github?: string;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** How a login half lets a person in: an email code, GitHub, an invite link, an email and a password. */
|
|
374
|
+
export type LoginMethod = "code" | "github" | "invite" | "password";
|
|
375
|
+
|
|
376
|
+
/** How a person signed in to the staff login, which the ledger records (`signin.recorded`): all but a password. */
|
|
377
|
+
export type SignInMethod = Exclude<LoginMethod, "password">;
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* What the app gives a connector's login half (`ConnectorSetup.login`) when it makes it: for a
|
|
381
|
+
* public site, once per site; for the staff login, once, with `staff`.
|
|
382
|
+
*/
|
|
364
383
|
export interface LoginContext {
|
|
365
384
|
/** The company's database (`resolved.databaseUrl`), where a login like Better Auth keeps its users. */
|
|
366
385
|
databaseUrl: string;
|
|
367
386
|
/**
|
|
368
|
-
* A 32-byte key derived from the deployment's `
|
|
387
|
+
* A 32-byte key derived from the deployment's `SANOMA_SIGNING_KEY` for this login (HKDF-SHA256,
|
|
369
388
|
* purpose `login <connector>`), hex; a login signs its cookies with it and never asks a person for a secret.
|
|
370
389
|
*/
|
|
371
390
|
key: string;
|
|
@@ -377,9 +396,24 @@ export interface LoginContext {
|
|
|
377
396
|
/**
|
|
378
397
|
* The origins the site answers on, scheme included and `:*` for any port:
|
|
379
398
|
* `["http://www.localhost:*", "https://sanoma.example"]`; a login needs the protocol for its
|
|
380
|
-
* cookies and allowed hosts.
|
|
399
|
+
* cookies and allowed hosts. Empty for the staff login, which answers on any host the app does.
|
|
381
400
|
*/
|
|
382
401
|
origins: readonly string[];
|
|
402
|
+
/**
|
|
403
|
+
* Sends a sign-in code to an email, through the config's `login.sender` (a run of the built-in
|
|
404
|
+
* `login-code` workflow, as the runtime's agent `login`); absent when the config names no
|
|
405
|
+
* sender, and then the login offers no code.
|
|
406
|
+
*/
|
|
407
|
+
sendCode?(email: string, code: string): Promise<void>;
|
|
408
|
+
/** For the staff login only: who it may let in, the company's social clients, and where it tells of a sign-in. */
|
|
409
|
+
staff?: {
|
|
410
|
+
/** Whether the people files declare the person `identity` names; a login never makes a user for anyone else. */
|
|
411
|
+
admits(identity: LoginIdentity): boolean;
|
|
412
|
+
/** The company's own OAuth clients, each a method when given. */
|
|
413
|
+
social: { github?: { clientId: string; clientSecret: string } };
|
|
414
|
+
/** Tells the app of a sign-in by `method` of the person `identity` names, which the ledger records. Must not throw. */
|
|
415
|
+
record(method: SignInMethod, identity: LoginIdentity): Promise<void>;
|
|
416
|
+
};
|
|
383
417
|
}
|
|
384
418
|
|
|
385
419
|
/**
|
|
@@ -390,23 +424,62 @@ export interface LoginContext {
|
|
|
390
424
|
export const CLIENT_IP_HEADER = "x-sanoma-client-ip";
|
|
391
425
|
|
|
392
426
|
/**
|
|
393
|
-
* A
|
|
394
|
-
* `
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
427
|
+
* A request's origin, https when a proxy that ends TLS says so (`x-forwarded-proto: https`), as
|
|
428
|
+
* Start's `getRequestUrl({ xForwardedProto: true })` reads it: the absolute URLs a page or an invite writes, and a login's cookies, take it.
|
|
429
|
+
*/
|
|
430
|
+
export function requestOrigin(request: Request): string {
|
|
431
|
+
const url = new URL(request.url);
|
|
432
|
+
if (request.headers.get("x-forwarded-proto") === "https") url.protocol = "https:";
|
|
433
|
+
return url.origin;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Whether a change comes from the page's own origin (a login's CSRF check): the browser says so, or its `Origin` names the request's host. */
|
|
437
|
+
export function sameOrigin(request: Request): boolean {
|
|
438
|
+
const site = request.headers.get("sec-fetch-site");
|
|
439
|
+
if (site !== null) return site === "same-origin";
|
|
440
|
+
const origin = URL.parse(request.headers.get("origin") ?? "");
|
|
441
|
+
return origin !== null && origin.host === request.headers.get("host");
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* A connector's login half: how a public site tells who a visitor is, and, made with
|
|
446
|
+
* `LoginContext.staff`, how the app's staff sign in. The app makes one with `login(context)` once
|
|
447
|
+
* per public site (and once for staff), when it starts; a login that keeps tables in the database
|
|
448
|
+
* migrates them itself there, serialized and idempotent. It answers the provider's subject and
|
|
449
|
+
* what it verified; the app makes the principal from it (`visitor:<connector>:<subject>`, in
|
|
450
|
+
* `visitors`, or the declared person the identity matches), so a connector never sets groups. A
|
|
451
|
+
* visitor's address, for rate limits, is the `CLIENT_IP_HEADER` header.
|
|
399
452
|
*/
|
|
400
453
|
export interface LoginHalf {
|
|
401
|
-
/**
|
|
402
|
-
|
|
454
|
+
/**
|
|
455
|
+
* Who signed in, as the provider's stable subject, with their email when the provider verified
|
|
456
|
+
* it and their GitHub login when they signed in with GitHub; null for a missing or bad cookie,
|
|
457
|
+
* for which it never throws.
|
|
458
|
+
*/
|
|
459
|
+
verify(request: Request): Promise<{ subject: string; email?: string; github?: string } | null>;
|
|
403
460
|
/** Answers its own routes, `/auth/<vendor>/*`; undefined for any other request. */
|
|
404
461
|
handle(request: Request): Promise<Response | undefined>;
|
|
405
462
|
/** The path a page POSTs to sign out, which `handle` serves; the app's own Sign out POSTs it with no body. */
|
|
406
463
|
signOut?: string;
|
|
464
|
+
/** How a person may sign in now: what the sign-in page offers. */
|
|
465
|
+
methods?: readonly LoginMethod[];
|
|
466
|
+
/**
|
|
467
|
+
* A staff login's invite for `email`, a declared person's: a one-use token, good for
|
|
468
|
+
* `INVITE_SECONDS`, which replaces the last one minted for that email. `request` is the
|
|
469
|
+
* request that asked for it, on the host the invite is for.
|
|
470
|
+
*/
|
|
471
|
+
invite?(email: string, request: Request): Promise<string>;
|
|
472
|
+
/**
|
|
473
|
+
* The invite a token is, while it is good: for whom, and the path (on the login's routes) that
|
|
474
|
+
* signs them in with it, using it up.
|
|
475
|
+
*/
|
|
476
|
+
invitation?(token: string): Promise<{ email: string; path: string } | undefined>;
|
|
407
477
|
close?(): Promise<void>;
|
|
408
478
|
}
|
|
409
479
|
|
|
480
|
+
/** How long an invite lasts: 24 hours. */
|
|
481
|
+
export const INVITE_SECONDS = 24 * 60 * 60;
|
|
482
|
+
|
|
410
483
|
/**
|
|
411
484
|
* The connector with `setup` laid over what it already carries, as a connector package exports
|
|
412
485
|
* it: `export const ghost = withSetup(ghostOps, { driver: ghostDriver, fake: fakeGhost, steps: ghostSteps })`. A
|
package/src/outline.ts
CHANGED
|
@@ -193,7 +193,7 @@ const PARAM =
|
|
|
193
193
|
"run must take ctx as its first parameter, by one name: destructured, its calls cannot be read into the graph";
|
|
194
194
|
const CTX_USE =
|
|
195
195
|
"ctx is only called, directly (`await ctx.<vendor>.<resource>.<op>(…)`, `ctx.approval(…)`, `ctx.sleep(…)`, " +
|
|
196
|
-
"`ctx.all([…])`) or read for ctx.runId: passed on, aliased or destructured, its calls are not in the graph";
|
|
196
|
+
"`ctx.all([…])`) or read for ctx.runId or ctx.startedBy: passed on, aliased or destructured, its calls are not in the graph";
|
|
197
197
|
const CTX_CALL = (what: string) =>
|
|
198
198
|
`ctx.${what} is not a call the graph can draw: an operation is ctx.<vendor>.<resource>.<op>(…), ` +
|
|
199
199
|
"and the built-ins are ctx.approval(…), ctx.sleep(…), ctx.all([…]) and ctx.now()";
|
|
@@ -237,7 +237,7 @@ interface Where {
|
|
|
237
237
|
}
|
|
238
238
|
|
|
239
239
|
/** The members of `ctx` that are read, not placed: no record, no node. */
|
|
240
|
-
const READS = new Set(["now", "runId"]);
|
|
240
|
+
const READS = new Set(["now", "runId", "startedBy"]);
|
|
241
241
|
|
|
242
242
|
/**
|
|
243
243
|
* The nodes of `fn`'s body (`run`'s, or a callback's the outline reads), each with an empty `path`
|
|
@@ -282,7 +282,7 @@ export function outlineBody(
|
|
|
282
282
|
return uses;
|
|
283
283
|
};
|
|
284
284
|
|
|
285
|
-
/** `ctx.runId`, read. */
|
|
285
|
+
/** `ctx.runId` or `ctx.startedBy`, read. */
|
|
286
286
|
const isRead = (member: Node) =>
|
|
287
287
|
!member.computed &&
|
|
288
288
|
unwrap(member.object)?.type === "Identifier" &&
|
package/src/resource.ts
CHANGED
|
@@ -238,7 +238,7 @@ const record = z.record(z.string(), z.unknown());
|
|
|
238
238
|
export const PLAN_ACTIONS = ["none", "create", "update", "replace", "delete"] as const;
|
|
239
239
|
export type PlanAction = (typeof PLAN_ACTIONS)[number];
|
|
240
240
|
|
|
241
|
-
/** The operations of a writable type that change its objects:
|
|
241
|
+
/** The operations of a writable type that change its objects (`isChangeOp`): apply's for a declared object, any workflow's for another. */
|
|
242
242
|
export const CHANGE_OPS = ["create", "update", "delete"] as const;
|
|
243
243
|
|
|
244
244
|
const opsOf = <S extends z.ZodObject>(schema: S) => {
|
|
@@ -293,7 +293,8 @@ export type ResourceOps<S extends z.ZodObject, W extends boolean = false> = Spec
|
|
|
293
293
|
|
|
294
294
|
/**
|
|
295
295
|
* True for an operation that changes a writable type's objects (`CHANGE_OPS`), which only the
|
|
296
|
-
* apply workflow may call
|
|
296
|
+
* apply workflow may call on a declared object (a workflow's call is refused one, `refuseDeclared`
|
|
297
|
+
* in `call.ts`): by its vendor, resource and name, so a copy of a connector is told too.
|
|
297
298
|
*/
|
|
298
299
|
export function isChangeOp(
|
|
299
300
|
op: { readonly vendor: string; readonly resource: string; readonly name: string },
|
|
@@ -565,8 +566,9 @@ function pathsIn(value: unknown, paths: ReadonlySet<string>, prefix = ""): strin
|
|
|
565
566
|
* returns the same: `{ gone: true }` when the object no longer exists, else its fresh
|
|
566
567
|
* `state`. A driver may import first when it is given no state.
|
|
567
568
|
*
|
|
568
|
-
* A `writable` type has the change operations too, which only the apply workflow may call
|
|
569
|
-
* (`isChangeOp`): `create`, `update` and `delete`
|
|
569
|
+
* A `writable` type has the change operations too, which only the apply workflow may call on a
|
|
570
|
+
* declared object, and any workflow on another (`isChangeOp`): `create`, `update` and `delete`
|
|
571
|
+
* effect `write` (`delete` idempotent), and `plan`:
|
|
570
572
|
*
|
|
571
573
|
* - `plan` (`read`, idempotent) takes `{ id?, state?, handle?, desired? }`, no `desired` for a
|
|
572
574
|
* delete and no `id` for a create, and returns `{ action, planned?, unknown, replace }`:
|
|
@@ -691,7 +693,8 @@ export function defineResource<
|
|
|
691
693
|
if (!underAny(path, writeOnly)) {
|
|
692
694
|
throw new Error(`${what}: ${at} is not write-only, so it takes a value, not a secret (${value.$secret})`);
|
|
693
695
|
}
|
|
694
|
-
|
|
696
|
+
const hostSecrets = host?.inputs.secrets;
|
|
697
|
+
if (isSanomaSecret(value.$secret) && !(hostSecrets !== undefined && underAny(path, new Set([hostSecrets])))) {
|
|
695
698
|
throw new Error(`${what}: ${at} names ${value.$secret}, one of sanoma's own: only a host's secrets may`);
|
|
696
699
|
}
|
|
697
700
|
secrets.add(at);
|
package/src/resources.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
ANONYMOUS_PRINCIPAL,
|
|
18
18
|
type At,
|
|
19
19
|
declaresPeople,
|
|
20
|
+
isToken,
|
|
20
21
|
isVisitor,
|
|
21
22
|
type PeopleResource,
|
|
22
23
|
personOf,
|
|
@@ -138,8 +139,10 @@ export function readPeople(root: string | undefined): DeclaredPeople {
|
|
|
138
139
|
* (`signIn`), of the person it matches (`personOf`: email, GitHub login, then id), never an agent.
|
|
139
140
|
* While no person is declared, whoever names an id is taken as given. A visitor's id (`isVisitor`)
|
|
140
141
|
* is taken whatever the files say, with the groups stamped here (`visitors`, or none for
|
|
141
|
-
* `anonymous`), never through a sign-in: the staff logins admit no visitor.
|
|
142
|
-
* `
|
|
142
|
+
* `anonymous`), never through a sign-in: the staff logins admit no visitor. A visitor keeps the
|
|
143
|
+
* `email` its login verified; a `subject` is never taken from the caller (a token's is its row's,
|
|
144
|
+
* which `SanomaClient.start` reads). Throws `NotAdmittedError` for anyone else, and for
|
|
145
|
+
* everyone while the people's files have problems.
|
|
143
146
|
*/
|
|
144
147
|
export function admit(
|
|
145
148
|
asking: { id?: string; email?: string; github?: string; groups?: string[]; onBehalfOf?: string },
|
|
@@ -149,7 +152,10 @@ export function admit(
|
|
|
149
152
|
const who = asking.github ?? asking.id ?? asking.email ?? "This sign-in";
|
|
150
153
|
if (asking.id !== undefined && isVisitor({ id: asking.id })) {
|
|
151
154
|
if (signIn) throw new NotAdmittedError(who);
|
|
152
|
-
|
|
155
|
+
if (asking.id === ANONYMOUS) return ANONYMOUS_PRINCIPAL;
|
|
156
|
+
// A token's groups are its row's (`SanomaClient.tokens`), which only the public front reads; a visitor's are stamped.
|
|
157
|
+
if (isToken({ id: asking.id })) return { id: asking.id, groups: [...(asking.groups ?? [])] };
|
|
158
|
+
return { id: asking.id, groups: [VISITORS], ...(asking.email && { email: asking.email }) };
|
|
153
159
|
}
|
|
154
160
|
if (problems.length) {
|
|
155
161
|
// The runtime's own agents are declared by no file, so a broken one does not stop them (`check`'s scenarios).
|
|
@@ -160,7 +166,10 @@ export function admit(
|
|
|
160
166
|
}
|
|
161
167
|
if (!declaresPeople(people)) {
|
|
162
168
|
const given = Principal.safeParse(asking).data;
|
|
163
|
-
if (given)
|
|
169
|
+
if (given) {
|
|
170
|
+
const { email: _email, subject: _subject, ...principal } = given;
|
|
171
|
+
return principal;
|
|
172
|
+
}
|
|
164
173
|
} else {
|
|
165
174
|
const id = signIn ? personOf(asking, people) : asking.id;
|
|
166
175
|
const admitted = id === undefined ? undefined : principalOf(id, people);
|
package/src/run.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ConnectionRef, ConnectionRegistry, ConnectionSnapshot } from "./connection.ts";
|
|
2
2
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
3
|
import type { Collection } from "./collection.ts";
|
|
4
|
+
import type { DeclaredObjects } from "./config.ts";
|
|
4
5
|
import type { DeadlineRegistry } from "./deadlines.ts";
|
|
5
6
|
import type { ApprovalState, Approver, Principal, WorkflowDefinition } from "./define.ts";
|
|
6
7
|
import type { Fake } from "./fake.ts";
|
|
@@ -53,6 +54,8 @@ export interface WorkerState {
|
|
|
53
54
|
/** The worker's `source`, when it was given one. */
|
|
54
55
|
source?: Source;
|
|
55
56
|
types: ReadonlyMap<string, Resource>;
|
|
57
|
+
/** The objects the data files declare, read at each call: a workflow's change operation may not aim at one (`refuseDeclared`). */
|
|
58
|
+
declaredObjects(): Promise<DeclaredObjects>;
|
|
56
59
|
/** The collections, by name, which scenarios' steps may name (`data.<name>`). */
|
|
57
60
|
collections: ReadonlyMap<string, Collection>;
|
|
58
61
|
steps: readonly Steps[];
|