openmates 0.27.0-alpha.2 → 0.27.0-alpha.21
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/dist/capacityReplayMarker-3NFJNDTZ.js +11 -0
- package/dist/chunk-4IRYB3YN.js +1320 -0
- package/dist/chunk-FRNW5Q2V.js +1188 -0
- package/dist/{chunk-S7KAERLW.js → chunk-UIW7XY2C.js} +8246 -4293
- package/dist/{cli-CoxPyuUN.d.ts → cli-C2G3DpGo.d.ts} +362 -15
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +7 -1
- package/dist/index.d.ts +18 -7
- package/dist/index.js +3 -1
- package/dist/privacyDaemon.d.ts +2 -0
- package/dist/privacyDaemon.js +6 -0
- package/dist/tasksCli-2NHPDTGV.js +66 -0
- package/fixtures/privacy-runtime/linux-arm64/LICENSE.ggml +21 -0
- package/fixtures/privacy-runtime/linux-arm64/LICENSE.privacy-filter +21 -0
- package/fixtures/privacy-runtime/linux-arm64/libggml-base.so +0 -0
- package/fixtures/privacy-runtime/linux-arm64/libggml-base.so.0 +0 -0
- package/fixtures/privacy-runtime/linux-arm64/libggml-base.so.0.15.1 +0 -0
- package/fixtures/privacy-runtime/linux-arm64/libggml-cpu.so +0 -0
- package/fixtures/privacy-runtime/linux-arm64/libggml-cpu.so.0 +0 -0
- package/fixtures/privacy-runtime/linux-arm64/libggml-cpu.so.0.15.1 +0 -0
- package/fixtures/privacy-runtime/linux-arm64/libggml.so +0 -0
- package/fixtures/privacy-runtime/linux-arm64/libggml.so.0 +0 -0
- package/fixtures/privacy-runtime/linux-arm64/libggml.so.0.15.1 +0 -0
- package/fixtures/privacy-runtime/linux-arm64/manifest.json +68 -0
- package/fixtures/privacy-runtime/linux-arm64/openmates-pii-worker +0 -0
- package/package.json +9 -7
|
@@ -0,0 +1,1320 @@
|
|
|
1
|
+
// src/tasksCli.ts
|
|
2
|
+
import { createHash as createHash2, createHmac as createHmac3, hkdfSync as hkdfSync2, randomBytes, randomUUID } from "crypto";
|
|
3
|
+
|
|
4
|
+
// src/crypto.ts
|
|
5
|
+
import { webcrypto, createHash, createHmac } from "crypto";
|
|
6
|
+
import { argon2id } from "hash-wasm";
|
|
7
|
+
import nacl from "tweetnacl";
|
|
8
|
+
var cryptoApi = globalThis.crypto ?? webcrypto;
|
|
9
|
+
var SIGNUP_KDF_ITERATIONS = 1e5;
|
|
10
|
+
var PASSWORD_KDF_V2 = Object.freeze({
|
|
11
|
+
version: 2,
|
|
12
|
+
algorithm: "argon2id",
|
|
13
|
+
memoryKiB: 65536,
|
|
14
|
+
iterations: 3,
|
|
15
|
+
parallelism: 1,
|
|
16
|
+
hashLength: 32
|
|
17
|
+
});
|
|
18
|
+
var AES_GCM_IV_LENGTH = 12;
|
|
19
|
+
var EMAIL_SALT_LENGTH = 16;
|
|
20
|
+
var MASTER_KEY_LENGTH = 32;
|
|
21
|
+
var NACL_NONCE_LENGTH = 24;
|
|
22
|
+
var CIPHERTEXT_MAGIC_0 = 79;
|
|
23
|
+
var CIPHERTEXT_MAGIC_1 = 77;
|
|
24
|
+
var FINGERPRINT_LENGTH = 4;
|
|
25
|
+
var CIPHERTEXT_HEADER_LENGTH = 2 + FINGERPRINT_LENGTH;
|
|
26
|
+
var API_KEY_PREFIX = "sk-api-";
|
|
27
|
+
var API_KEY_RANDOM_LENGTH = 32;
|
|
28
|
+
var API_KEY_CHARS = "ABCDEFGHIJKLMNPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz123456789";
|
|
29
|
+
var CHAT_RECOVERY_PROTOCOL_VERSION = 1;
|
|
30
|
+
var CHAT_RECOVERY_MAX_PAYLOAD_BYTES = 16 * 1024 * 1024;
|
|
31
|
+
var CHAT_RECOVERY_KEY_BYTES = 32;
|
|
32
|
+
var CHAT_RECOVERY_NONCE_BYTES = 12;
|
|
33
|
+
var CHAT_RECOVERY_AAD_PREFIX = new TextEncoder().encode("OMCR1");
|
|
34
|
+
var CHAT_RECOVERY_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
35
|
+
function base64ToBytes(input) {
|
|
36
|
+
return new Uint8Array(Buffer.from(input, "base64"));
|
|
37
|
+
}
|
|
38
|
+
function bytesToBase64(input) {
|
|
39
|
+
return Buffer.from(input).toString("base64");
|
|
40
|
+
}
|
|
41
|
+
function bytesToBase64Url(input) {
|
|
42
|
+
return Buffer.from(input).toString("base64url");
|
|
43
|
+
}
|
|
44
|
+
function toArrayBuffer(input) {
|
|
45
|
+
const output = new ArrayBuffer(input.byteLength);
|
|
46
|
+
new Uint8Array(output).set(input);
|
|
47
|
+
return output;
|
|
48
|
+
}
|
|
49
|
+
function base64UrlToBytes(input, field, expectedLength) {
|
|
50
|
+
if (!input || input.includes("=")) {
|
|
51
|
+
throw new Error(`${field} must be non-empty unpadded base64url`);
|
|
52
|
+
}
|
|
53
|
+
const decoded = new Uint8Array(Buffer.from(input, "base64url"));
|
|
54
|
+
if (bytesToBase64Url(decoded) !== input) {
|
|
55
|
+
throw new Error(`${field} must be canonical base64url`);
|
|
56
|
+
}
|
|
57
|
+
if (expectedLength !== void 0 && decoded.length !== expectedLength) {
|
|
58
|
+
throw new Error(`${field} must decode to ${expectedLength} bytes`);
|
|
59
|
+
}
|
|
60
|
+
return decoded;
|
|
61
|
+
}
|
|
62
|
+
async function deriveTeamInviteKey(input) {
|
|
63
|
+
const secretBytes = base64UrlToBytes(input.inviteSecret, "invite secret", 32);
|
|
64
|
+
const salt = await sha256(new TextEncoder().encode("openmates:team-invite:v1"));
|
|
65
|
+
const info = concatBytes(
|
|
66
|
+
lengthPrefix(new TextEncoder().encode(input.recipientEmail.trim().toLowerCase())),
|
|
67
|
+
lengthPrefix(new TextEncoder().encode(input.inviteId)),
|
|
68
|
+
lengthPrefix(new TextEncoder().encode(input.teamId)),
|
|
69
|
+
lengthPrefix(new TextEncoder().encode(input.origin.replace(/\/$/, "")))
|
|
70
|
+
);
|
|
71
|
+
return hkdfSha256(secretBytes, salt, info);
|
|
72
|
+
}
|
|
73
|
+
function uint32(value, field) {
|
|
74
|
+
if (!Number.isInteger(value) || value < 0 || value > 4294967295) {
|
|
75
|
+
throw new Error(`${field} must be an unsigned 32-bit integer`);
|
|
76
|
+
}
|
|
77
|
+
const encoded = new Uint8Array(4);
|
|
78
|
+
new DataView(encoded.buffer).setUint32(0, value, false);
|
|
79
|
+
return encoded;
|
|
80
|
+
}
|
|
81
|
+
function recoveryKeyVersion(value) {
|
|
82
|
+
if (value === 0) {
|
|
83
|
+
throw new Error("key_version must be greater than zero");
|
|
84
|
+
}
|
|
85
|
+
return uint32(value, "key_version");
|
|
86
|
+
}
|
|
87
|
+
function canonicalUuid(value, field) {
|
|
88
|
+
if (!CHAT_RECOVERY_UUID_PATTERN.test(value)) {
|
|
89
|
+
throw new Error(`${field} must be a canonical lowercase UUID`);
|
|
90
|
+
}
|
|
91
|
+
return new TextEncoder().encode(value);
|
|
92
|
+
}
|
|
93
|
+
function concatBytes(...values) {
|
|
94
|
+
const output = new Uint8Array(values.reduce((total, value) => total + value.length, 0));
|
|
95
|
+
let offset = 0;
|
|
96
|
+
for (const value of values) {
|
|
97
|
+
output.set(value, offset);
|
|
98
|
+
offset += value.length;
|
|
99
|
+
}
|
|
100
|
+
return output;
|
|
101
|
+
}
|
|
102
|
+
function lengthPrefix(value) {
|
|
103
|
+
return concatBytes(uint32(value.length, "length"), value);
|
|
104
|
+
}
|
|
105
|
+
async function sha256(input) {
|
|
106
|
+
return new Uint8Array(await cryptoApi.subtle.digest("SHA-256", toArrayBuffer(input)));
|
|
107
|
+
}
|
|
108
|
+
async function hkdfSha256(input, salt, info) {
|
|
109
|
+
const key = await cryptoApi.subtle.importKey("raw", toArrayBuffer(input), "HKDF", false, ["deriveBits"]);
|
|
110
|
+
const bits = await cryptoApi.subtle.deriveBits(
|
|
111
|
+
{
|
|
112
|
+
name: "HKDF",
|
|
113
|
+
hash: "SHA-256",
|
|
114
|
+
salt: toArrayBuffer(salt),
|
|
115
|
+
info: toArrayBuffer(info)
|
|
116
|
+
},
|
|
117
|
+
key,
|
|
118
|
+
256
|
|
119
|
+
);
|
|
120
|
+
return new Uint8Array(bits);
|
|
121
|
+
}
|
|
122
|
+
function recoveryOutputAssociatedData(identity) {
|
|
123
|
+
if (!["message", "embed", "diff", "summary", "checkpoint"].includes(identity.outputKind)) {
|
|
124
|
+
throw new Error("unsupported recovery output kind");
|
|
125
|
+
}
|
|
126
|
+
if (!Number.isSafeInteger(identity.outputVersion) || identity.outputVersion < 1) {
|
|
127
|
+
throw new Error("output version must be positive");
|
|
128
|
+
}
|
|
129
|
+
const subject = new TextEncoder().encode(identity.subjectId);
|
|
130
|
+
if (!subject.length || subject.length > 255 || Array.from(identity.subjectId).some((character) => character.charCodeAt(0) < 32)) {
|
|
131
|
+
throw new Error("subjectId must be a bounded stable identifier");
|
|
132
|
+
}
|
|
133
|
+
return concatBytes(
|
|
134
|
+
new TextEncoder().encode("OMCR2"),
|
|
135
|
+
...[identity.ownerId, identity.rootChatId, identity.targetChatId, identity.turnId, identity.recordId].map((value) => lengthPrefix(canonicalUuid(value, "recovery_identity"))),
|
|
136
|
+
lengthPrefix(subject),
|
|
137
|
+
lengthPrefix(new TextEncoder().encode(identity.outputKind)),
|
|
138
|
+
recoveryKeyVersion(identity.keyVersion),
|
|
139
|
+
uint32(identity.outputVersion, "output_version")
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
async function deriveChatCompletionRecoveryKeypair(chatKey, chatId, keyVersion) {
|
|
143
|
+
const rawChatKey = base64UrlToBytes(chatKey, "chat_key", CHAT_RECOVERY_KEY_BYTES);
|
|
144
|
+
const salt = await sha256(new TextEncoder().encode("openmates:chat-recovery:v1"));
|
|
145
|
+
const info = concatBytes(lengthPrefix(canonicalUuid(chatId, "chat_id")), recoveryKeyVersion(keyVersion));
|
|
146
|
+
const privateKey = await hkdfSha256(rawChatKey, salt, info);
|
|
147
|
+
return {
|
|
148
|
+
privateKey: bytesToBase64Url(privateKey),
|
|
149
|
+
publicKey: bytesToBase64Url(nacl.scalarMult.base(privateKey))
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
function buildRecoveryAssociatedData(values) {
|
|
153
|
+
const associatedData = concatBytes(
|
|
154
|
+
CHAT_RECOVERY_AAD_PREFIX,
|
|
155
|
+
lengthPrefix(canonicalUuid(values.owner_id, "owner_id")),
|
|
156
|
+
lengthPrefix(canonicalUuid(values.chat_id, "chat_id")),
|
|
157
|
+
lengthPrefix(canonicalUuid(values.turn_id, "turn_id")),
|
|
158
|
+
lengthPrefix(canonicalUuid(values.job_id, "job_id")),
|
|
159
|
+
lengthPrefix(canonicalUuid(values.assistant_message_id, "assistant_message_id")),
|
|
160
|
+
recoveryKeyVersion(values.key_version)
|
|
161
|
+
);
|
|
162
|
+
return bytesToBase64Url(associatedData);
|
|
163
|
+
}
|
|
164
|
+
function recoveryAssociatedData(identity) {
|
|
165
|
+
return base64UrlToBytes(
|
|
166
|
+
buildRecoveryAssociatedData({
|
|
167
|
+
owner_id: identity.ownerId,
|
|
168
|
+
chat_id: identity.chatId,
|
|
169
|
+
turn_id: identity.turnId,
|
|
170
|
+
job_id: identity.jobId,
|
|
171
|
+
assistant_message_id: identity.assistantMessageId,
|
|
172
|
+
key_version: identity.keyVersion
|
|
173
|
+
}),
|
|
174
|
+
"associated_data"
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
async function recoveryEnvelopeKey(sharedSecret, associatedData) {
|
|
178
|
+
if (sharedSecret.every((value) => value === 0)) {
|
|
179
|
+
throw new Error("X25519 shared secret must not be all zero");
|
|
180
|
+
}
|
|
181
|
+
const salt = await sha256(new TextEncoder().encode("openmates:chat-recovery-envelope:v1"));
|
|
182
|
+
return hkdfSha256(sharedSecret, salt, await sha256(associatedData));
|
|
183
|
+
}
|
|
184
|
+
async function openChatCompletionRecoveryEnvelope(envelope, options) {
|
|
185
|
+
if (!envelope || Object.keys(envelope).sort().join(",") !== "ciphertext,epk,nonce,v" || envelope.v !== CHAT_RECOVERY_PROTOCOL_VERSION) {
|
|
186
|
+
throw new Error("invalid recovery envelope fields or version");
|
|
187
|
+
}
|
|
188
|
+
const recoveryPrivateKey = base64UrlToBytes(
|
|
189
|
+
options.recoveryPrivateKey,
|
|
190
|
+
"recovery_private_key",
|
|
191
|
+
CHAT_RECOVERY_KEY_BYTES
|
|
192
|
+
);
|
|
193
|
+
const ephemeralPublicKey = base64UrlToBytes(envelope.epk, "epk", CHAT_RECOVERY_KEY_BYTES);
|
|
194
|
+
const nonce = base64UrlToBytes(envelope.nonce, "nonce", CHAT_RECOVERY_NONCE_BYTES);
|
|
195
|
+
const ciphertext = base64UrlToBytes(envelope.ciphertext, "ciphertext");
|
|
196
|
+
if (ciphertext.length < 16 || ciphertext.length - 16 > CHAT_RECOVERY_MAX_PAYLOAD_BYTES) {
|
|
197
|
+
throw new Error("ciphertext payload size is invalid");
|
|
198
|
+
}
|
|
199
|
+
const associatedData = recoveryAssociatedData(options);
|
|
200
|
+
const sharedSecret = nacl.scalarMult(recoveryPrivateKey, ephemeralPublicKey);
|
|
201
|
+
const envelopeKeyBytes = await recoveryEnvelopeKey(sharedSecret, associatedData);
|
|
202
|
+
const envelopeKey = await cryptoApi.subtle.importKey(
|
|
203
|
+
"raw",
|
|
204
|
+
toArrayBuffer(envelopeKeyBytes),
|
|
205
|
+
{ name: "AES-GCM" },
|
|
206
|
+
false,
|
|
207
|
+
["decrypt"]
|
|
208
|
+
);
|
|
209
|
+
return new Uint8Array(await cryptoApi.subtle.decrypt(
|
|
210
|
+
{ name: "AES-GCM", iv: toArrayBuffer(nonce), additionalData: toArrayBuffer(associatedData) },
|
|
211
|
+
envelopeKey,
|
|
212
|
+
toArrayBuffer(ciphertext)
|
|
213
|
+
));
|
|
214
|
+
}
|
|
215
|
+
async function openRecoveryOutputEnvelope(envelope, options) {
|
|
216
|
+
if (!envelope || Object.keys(envelope).sort().join(",") !== "ciphertext,epk,nonce,v" || envelope.v !== 2) {
|
|
217
|
+
throw new Error("invalid recovery output envelope fields or version");
|
|
218
|
+
}
|
|
219
|
+
const privateKey = base64UrlToBytes(options.recoveryPrivateKey, "recovery_private_key", CHAT_RECOVERY_KEY_BYTES);
|
|
220
|
+
const epk = base64UrlToBytes(envelope.epk, "epk", CHAT_RECOVERY_KEY_BYTES);
|
|
221
|
+
const nonce = base64UrlToBytes(envelope.nonce, "nonce", CHAT_RECOVERY_NONCE_BYTES);
|
|
222
|
+
const ciphertext = base64UrlToBytes(envelope.ciphertext, "ciphertext");
|
|
223
|
+
if (ciphertext.length < 16 || ciphertext.length - 16 > CHAT_RECOVERY_MAX_PAYLOAD_BYTES) {
|
|
224
|
+
throw new Error("ciphertext payload size is invalid");
|
|
225
|
+
}
|
|
226
|
+
const aad = recoveryOutputAssociatedData(options);
|
|
227
|
+
const sharedSecret = nacl.scalarMult(privateKey, epk);
|
|
228
|
+
const keyBytes = await recoveryEnvelopeKey(sharedSecret, aad);
|
|
229
|
+
const key = await cryptoApi.subtle.importKey("raw", toArrayBuffer(keyBytes), { name: "AES-GCM" }, false, ["decrypt"]);
|
|
230
|
+
return new Uint8Array(await cryptoApi.subtle.decrypt(
|
|
231
|
+
{ name: "AES-GCM", iv: toArrayBuffer(nonce), additionalData: toArrayBuffer(aad) },
|
|
232
|
+
key,
|
|
233
|
+
toArrayBuffer(ciphertext)
|
|
234
|
+
));
|
|
235
|
+
}
|
|
236
|
+
function generateSalt(length = EMAIL_SALT_LENGTH) {
|
|
237
|
+
return cryptoApi.getRandomValues(new Uint8Array(length));
|
|
238
|
+
}
|
|
239
|
+
function generateSecureRecoveryKey(length = 24) {
|
|
240
|
+
const uppercaseChars = "ABCDEFGHJKLMNPQRSTUVWXYZ";
|
|
241
|
+
const lowercaseChars = "abcdefghijkmnopqrstuvwxyz";
|
|
242
|
+
const numberChars = "23456789";
|
|
243
|
+
const specialChars = "#-=+_&%$";
|
|
244
|
+
const allChars = uppercaseChars + lowercaseChars + numberChars + specialChars;
|
|
245
|
+
const result = new Array(length);
|
|
246
|
+
const requiredSets = [uppercaseChars, lowercaseChars, numberChars, specialChars];
|
|
247
|
+
for (let index = 0; index < requiredSets.length && index < length; index += 1) {
|
|
248
|
+
const chars = requiredSets[index];
|
|
249
|
+
result[index] = chars.charAt(secureRandomIndex(chars.length));
|
|
250
|
+
}
|
|
251
|
+
for (let index = requiredSets.length; index < length; index += 1) {
|
|
252
|
+
result[index] = allChars.charAt(secureRandomIndex(allChars.length));
|
|
253
|
+
}
|
|
254
|
+
for (let index = result.length - 1; index > 0; index -= 1) {
|
|
255
|
+
const swapIndex = secureRandomIndex(index + 1);
|
|
256
|
+
[result[index], result[swapIndex]] = [result[swapIndex], result[index]];
|
|
257
|
+
}
|
|
258
|
+
return result.join("");
|
|
259
|
+
}
|
|
260
|
+
function secureRandomIndex(upperBound) {
|
|
261
|
+
if (!Number.isInteger(upperBound) || upperBound <= 0 || upperBound > 256) {
|
|
262
|
+
throw new RangeError("secure random upper bound must be between 1 and 256");
|
|
263
|
+
}
|
|
264
|
+
const maxUnbiasedValue = Math.floor(256 / upperBound) * upperBound;
|
|
265
|
+
while (true) {
|
|
266
|
+
const value = cryptoApi.getRandomValues(new Uint8Array(1))[0];
|
|
267
|
+
if (value < maxUnbiasedValue) return value % upperBound;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
async function hashEmail(email) {
|
|
271
|
+
const hashBuffer = await cryptoApi.subtle.digest(
|
|
272
|
+
"SHA-256",
|
|
273
|
+
new TextEncoder().encode(email)
|
|
274
|
+
);
|
|
275
|
+
return bytesToBase64(new Uint8Array(hashBuffer));
|
|
276
|
+
}
|
|
277
|
+
async function hashKey(key, salt) {
|
|
278
|
+
const keyBytes = new TextEncoder().encode(key);
|
|
279
|
+
const dataToHash = salt ? new Uint8Array(keyBytes.length + salt.length) : keyBytes;
|
|
280
|
+
if (salt) {
|
|
281
|
+
dataToHash.set(keyBytes);
|
|
282
|
+
dataToHash.set(salt, keyBytes.length);
|
|
283
|
+
}
|
|
284
|
+
const hashBuffer = await cryptoApi.subtle.digest("SHA-256", toArrayBuffer(dataToHash));
|
|
285
|
+
return bytesToBase64(new Uint8Array(hashBuffer));
|
|
286
|
+
}
|
|
287
|
+
async function deriveKeyFromPassword(password, salt) {
|
|
288
|
+
const keyMaterial = await cryptoApi.subtle.importKey(
|
|
289
|
+
"raw",
|
|
290
|
+
new TextEncoder().encode(password),
|
|
291
|
+
"PBKDF2",
|
|
292
|
+
false,
|
|
293
|
+
["deriveBits"]
|
|
294
|
+
);
|
|
295
|
+
const derivedBits = await cryptoApi.subtle.deriveBits(
|
|
296
|
+
{
|
|
297
|
+
name: "PBKDF2",
|
|
298
|
+
salt: toArrayBuffer(salt),
|
|
299
|
+
iterations: SIGNUP_KDF_ITERATIONS,
|
|
300
|
+
hash: "SHA-256"
|
|
301
|
+
},
|
|
302
|
+
keyMaterial,
|
|
303
|
+
256
|
|
304
|
+
);
|
|
305
|
+
return new Uint8Array(derivedBits);
|
|
306
|
+
}
|
|
307
|
+
async function derivePasswordMaterialV2(password, salt) {
|
|
308
|
+
if (!password || salt.length < 16) throw new Error("Argon2id password derivation requires a password and at least 16 salt bytes");
|
|
309
|
+
const output = await argon2id({
|
|
310
|
+
password,
|
|
311
|
+
salt,
|
|
312
|
+
memorySize: PASSWORD_KDF_V2.memoryKiB,
|
|
313
|
+
iterations: PASSWORD_KDF_V2.iterations,
|
|
314
|
+
parallelism: PASSWORD_KDF_V2.parallelism,
|
|
315
|
+
hashLength: PASSWORD_KDF_V2.hashLength,
|
|
316
|
+
outputType: "binary"
|
|
317
|
+
});
|
|
318
|
+
if (!(output instanceof Uint8Array) || output.length !== PASSWORD_KDF_V2.hashLength) {
|
|
319
|
+
throw new Error("Argon2id password derivation failed");
|
|
320
|
+
}
|
|
321
|
+
return output;
|
|
322
|
+
}
|
|
323
|
+
async function derivePasswordKeysV2(password, userEmailSalt) {
|
|
324
|
+
const material = await derivePasswordMaterialV2(password, userEmailSalt);
|
|
325
|
+
const hkdfKey = await cryptoApi.subtle.importKey("raw", toArrayBuffer(material), "HKDF", false, ["deriveBits"]);
|
|
326
|
+
const expand = async (label) => new Uint8Array(await cryptoApi.subtle.deriveBits({
|
|
327
|
+
name: "HKDF",
|
|
328
|
+
hash: "SHA-256",
|
|
329
|
+
salt: new Uint8Array(0),
|
|
330
|
+
info: new TextEncoder().encode(label)
|
|
331
|
+
}, hkdfKey, 256));
|
|
332
|
+
try {
|
|
333
|
+
return {
|
|
334
|
+
authKey: await expand("openmates/password-v2/auth"),
|
|
335
|
+
wrapKey: await expand("openmates/password-v2/wrap")
|
|
336
|
+
};
|
|
337
|
+
} finally {
|
|
338
|
+
material.fill(0);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
function passwordProofV2(authKey, nonceB64url, purpose) {
|
|
342
|
+
if (!/^[a-z0-9_]+$/.test(purpose)) throw new Error("Invalid password proof purpose");
|
|
343
|
+
const nonce = base64UrlToBytes(nonceB64url, "password challenge nonce", 32);
|
|
344
|
+
return createHmac("sha256", authKey).update(Buffer.from(`openmates/password-v2/proof\0${purpose}\0`, "utf8")).update(nonce).digest("base64url");
|
|
345
|
+
}
|
|
346
|
+
async function unwrapPasswordMasterKey(params) {
|
|
347
|
+
let wrappingKeyBytes = null;
|
|
348
|
+
try {
|
|
349
|
+
const salt = base64ToBytes(params.saltB64);
|
|
350
|
+
if (params.credentialVersion === 2) {
|
|
351
|
+
const keys = await derivePasswordKeysV2(params.password, salt);
|
|
352
|
+
keys.authKey.fill(0);
|
|
353
|
+
wrappingKeyBytes = keys.wrapKey;
|
|
354
|
+
} else if (params.credentialVersion == null || params.credentialVersion === 1) {
|
|
355
|
+
wrappingKeyBytes = await deriveKeyFromPassword(params.password, salt);
|
|
356
|
+
} else {
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
const wrappingKey = await cryptoApi.subtle.importKey(
|
|
360
|
+
"raw",
|
|
361
|
+
toArrayBuffer(wrappingKeyBytes),
|
|
362
|
+
{ name: "AES-GCM" },
|
|
363
|
+
false,
|
|
364
|
+
["decrypt"]
|
|
365
|
+
);
|
|
366
|
+
return new Uint8Array(await cryptoApi.subtle.decrypt(
|
|
367
|
+
{ name: "AES-GCM", iv: toArrayBuffer(base64ToBytes(params.keyIvB64)) },
|
|
368
|
+
wrappingKey,
|
|
369
|
+
toArrayBuffer(base64ToBytes(params.encryptedMasterKeyB64))
|
|
370
|
+
));
|
|
371
|
+
} catch {
|
|
372
|
+
return null;
|
|
373
|
+
} finally {
|
|
374
|
+
wrappingKeyBytes?.fill(0);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
async function encryptEmail(email, key) {
|
|
378
|
+
if (key.length !== MASTER_KEY_LENGTH) {
|
|
379
|
+
throw new Error(`Email encryption key must be 32 bytes, got ${key.length}`);
|
|
380
|
+
}
|
|
381
|
+
const nonce = nacl.randomBytes(NACL_NONCE_LENGTH);
|
|
382
|
+
const ciphertext = nacl.secretbox(new TextEncoder().encode(email), nonce, key);
|
|
383
|
+
const combined = new Uint8Array(nonce.length + ciphertext.length);
|
|
384
|
+
combined.set(nonce);
|
|
385
|
+
combined.set(ciphertext, nonce.length);
|
|
386
|
+
return bytesToBase64(combined);
|
|
387
|
+
}
|
|
388
|
+
async function encryptRawKeyWithAesGcm(rawKey, wrappingKeyBytes) {
|
|
389
|
+
const iv = cryptoApi.getRandomValues(new Uint8Array(AES_GCM_IV_LENGTH));
|
|
390
|
+
const wrappingKey = await cryptoApi.subtle.importKey(
|
|
391
|
+
"raw",
|
|
392
|
+
toArrayBuffer(wrappingKeyBytes),
|
|
393
|
+
{ name: "AES-GCM" },
|
|
394
|
+
false,
|
|
395
|
+
["encrypt"]
|
|
396
|
+
);
|
|
397
|
+
const encrypted = await cryptoApi.subtle.encrypt(
|
|
398
|
+
{ name: "AES-GCM", iv: toArrayBuffer(iv) },
|
|
399
|
+
wrappingKey,
|
|
400
|
+
toArrayBuffer(rawKey)
|
|
401
|
+
);
|
|
402
|
+
return {
|
|
403
|
+
wrapped: bytesToBase64(new Uint8Array(encrypted)),
|
|
404
|
+
iv: bytesToBase64(iv)
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
async function createPasswordMigrationMaterialV2(password, masterKeyB64, userEmailSaltB64) {
|
|
408
|
+
const userEmailSalt = base64ToBytes(userEmailSaltB64);
|
|
409
|
+
const { authKey, wrapKey } = await derivePasswordKeysV2(password, userEmailSalt);
|
|
410
|
+
try {
|
|
411
|
+
const wrapper = await encryptRawKeyWithAesGcm(base64ToBytes(masterKeyB64), wrapKey);
|
|
412
|
+
return {
|
|
413
|
+
oldLookupHash: await hashKey(password, userEmailSalt),
|
|
414
|
+
passwordAuthKey: Buffer.from(authKey).toString("base64url"),
|
|
415
|
+
encryptedMasterKey: wrapper.wrapped,
|
|
416
|
+
saltB64: userEmailSaltB64,
|
|
417
|
+
keyIv: wrapper.iv
|
|
418
|
+
};
|
|
419
|
+
} finally {
|
|
420
|
+
authKey.fill(0);
|
|
421
|
+
wrapKey.fill(0);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
async function createSignupCryptoMaterial(email, password) {
|
|
425
|
+
const normalizedEmail = email.trim().toLowerCase();
|
|
426
|
+
const emailSalt = generateSalt(EMAIL_SALT_LENGTH);
|
|
427
|
+
const masterKey = generateSalt(MASTER_KEY_LENGTH);
|
|
428
|
+
const emailEncryptionKeyB64 = await deriveEmailEncryptionKeyB64(normalizedEmail, bytesToBase64(emailSalt));
|
|
429
|
+
const emailEncryptionKey = base64ToBytes(emailEncryptionKeyB64);
|
|
430
|
+
const { authKey, wrapKey } = await derivePasswordKeysV2(password, emailSalt);
|
|
431
|
+
const encryptedMasterKey = await encryptRawKeyWithAesGcm(masterKey, wrapKey);
|
|
432
|
+
const passwordAuthKey = Buffer.from(authKey).toString("base64url");
|
|
433
|
+
authKey.fill(0);
|
|
434
|
+
wrapKey.fill(0);
|
|
435
|
+
return {
|
|
436
|
+
hashedEmail: await hashEmail(normalizedEmail),
|
|
437
|
+
encryptedEmail: await encryptEmail(normalizedEmail, emailEncryptionKey),
|
|
438
|
+
encryptedEmailWithMasterKey: await encryptWithAesGcmCombined(normalizedEmail, masterKey),
|
|
439
|
+
userEmailSaltB64: bytesToBase64(emailSalt),
|
|
440
|
+
emailEncryptionKeyB64,
|
|
441
|
+
masterKeyB64: bytesToBase64(masterKey),
|
|
442
|
+
encryptedMasterKey: encryptedMasterKey.wrapped,
|
|
443
|
+
keyIv: encryptedMasterKey.iv,
|
|
444
|
+
saltB64: bytesToBase64(emailSalt),
|
|
445
|
+
credentialVersion: 2,
|
|
446
|
+
passwordAuthKey
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
async function createRecoveryKeyMaterial(masterKeyB64, userEmailSaltB64) {
|
|
450
|
+
const recoveryKey = generateSecureRecoveryKey();
|
|
451
|
+
const userEmailSalt = base64ToBytes(userEmailSaltB64);
|
|
452
|
+
const wrappingSalt = generateSalt(EMAIL_SALT_LENGTH);
|
|
453
|
+
const wrappingKey = await deriveKeyFromPassword(recoveryKey, wrappingSalt);
|
|
454
|
+
const encryptedMasterKey = await encryptRawKeyWithAesGcm(base64ToBytes(masterKeyB64), wrappingKey);
|
|
455
|
+
return {
|
|
456
|
+
recoveryKey,
|
|
457
|
+
lookupHash: await hashKey(recoveryKey, userEmailSalt),
|
|
458
|
+
wrappedMasterKey: encryptedMasterKey.wrapped,
|
|
459
|
+
keyIv: encryptedMasterKey.iv,
|
|
460
|
+
saltB64: bytesToBase64(wrappingSalt)
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
function splitApiKeyCredential(value) {
|
|
464
|
+
const [bearer, decryptionSecret, extra] = value.split(".");
|
|
465
|
+
if (extra || decryptionSecret !== void 0 && !decryptionSecret) {
|
|
466
|
+
throw new Error("Invalid API key setup credential");
|
|
467
|
+
}
|
|
468
|
+
return { bearer, decryptionSecret: decryptionSecret ?? null };
|
|
469
|
+
}
|
|
470
|
+
function generateApiKey() {
|
|
471
|
+
let result = API_KEY_PREFIX;
|
|
472
|
+
const maxUnbiasedValue = Math.floor(256 / API_KEY_CHARS.length) * API_KEY_CHARS.length;
|
|
473
|
+
while (result.length < API_KEY_PREFIX.length + API_KEY_RANDOM_LENGTH) {
|
|
474
|
+
const randomValues = cryptoApi.getRandomValues(new Uint8Array(API_KEY_RANDOM_LENGTH));
|
|
475
|
+
for (const value of randomValues) {
|
|
476
|
+
if (value >= maxUnbiasedValue) continue;
|
|
477
|
+
result += API_KEY_CHARS.charAt(value % API_KEY_CHARS.length);
|
|
478
|
+
if (result.length >= API_KEY_PREFIX.length + API_KEY_RANDOM_LENGTH) break;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return result;
|
|
482
|
+
}
|
|
483
|
+
function sha256Hex(input) {
|
|
484
|
+
return createHash("sha256").update(input).digest("hex");
|
|
485
|
+
}
|
|
486
|
+
async function createApiKeyCryptoMaterial(name, masterKeyB64) {
|
|
487
|
+
const masterKey = base64ToBytes(masterKeyB64);
|
|
488
|
+
const bearer = generateApiKey();
|
|
489
|
+
const decryptionSecret = generateApiKey().slice(API_KEY_PREFIX.length);
|
|
490
|
+
const apiKey = `${bearer}.${decryptionSecret}`;
|
|
491
|
+
const keyPrefix = `${bearer.slice(0, 12)}...`;
|
|
492
|
+
const wrappingSalt = generateSalt(EMAIL_SALT_LENGTH);
|
|
493
|
+
const wrappingKey = await deriveKeyFromPassword(decryptionSecret, wrappingSalt);
|
|
494
|
+
const encryptedMasterKey = await encryptRawKeyWithAesGcm(masterKey, wrappingKey);
|
|
495
|
+
return {
|
|
496
|
+
apiKey,
|
|
497
|
+
apiKeyHash: sha256Hex(bearer),
|
|
498
|
+
encryptedName: await encryptWithAesGcmCombined(name.trim(), masterKey),
|
|
499
|
+
encryptedKeyPrefix: await encryptWithAesGcmCombined(keyPrefix, masterKey),
|
|
500
|
+
encryptedMasterKey: encryptedMasterKey.wrapped,
|
|
501
|
+
keyIv: encryptedMasterKey.iv,
|
|
502
|
+
saltB64: bytesToBase64(wrappingSalt)
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
async function unwrapApiKeyMasterKey(params) {
|
|
506
|
+
try {
|
|
507
|
+
const { decryptionSecret } = splitApiKeyCredential(params.apiKey);
|
|
508
|
+
const wrappingKeyBytes = await deriveKeyFromPassword(decryptionSecret ?? params.apiKey, base64ToBytes(params.saltB64));
|
|
509
|
+
const wrappingKey = await cryptoApi.subtle.importKey(
|
|
510
|
+
"raw",
|
|
511
|
+
toArrayBuffer(wrappingKeyBytes),
|
|
512
|
+
{ name: "AES-GCM" },
|
|
513
|
+
false,
|
|
514
|
+
["decrypt"]
|
|
515
|
+
);
|
|
516
|
+
const decrypted = await cryptoApi.subtle.decrypt(
|
|
517
|
+
{ name: "AES-GCM", iv: toArrayBuffer(base64ToBytes(params.keyIvB64)) },
|
|
518
|
+
wrappingKey,
|
|
519
|
+
toArrayBuffer(base64ToBytes(params.encryptedMasterKeyB64))
|
|
520
|
+
);
|
|
521
|
+
return new Uint8Array(decrypted);
|
|
522
|
+
} catch {
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
async function decryptWithAesGcmCombined(encryptedWithIvB64, rawKeyBytes, associatedData) {
|
|
527
|
+
try {
|
|
528
|
+
const combined = base64ToBytes(encryptedWithIvB64);
|
|
529
|
+
if (combined.length <= AES_GCM_IV_LENGTH) {
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
let offset = 0;
|
|
533
|
+
if (combined.length > CIPHERTEXT_HEADER_LENGTH + AES_GCM_IV_LENGTH && combined[0] === CIPHERTEXT_MAGIC_0 && combined[1] === CIPHERTEXT_MAGIC_1) {
|
|
534
|
+
offset = CIPHERTEXT_HEADER_LENGTH;
|
|
535
|
+
}
|
|
536
|
+
const iv = combined.slice(offset, offset + AES_GCM_IV_LENGTH);
|
|
537
|
+
const ciphertext = combined.slice(offset + AES_GCM_IV_LENGTH);
|
|
538
|
+
const key = await cryptoApi.subtle.importKey(
|
|
539
|
+
"raw",
|
|
540
|
+
toArrayBuffer(rawKeyBytes),
|
|
541
|
+
{ name: "AES-GCM" },
|
|
542
|
+
false,
|
|
543
|
+
["decrypt"]
|
|
544
|
+
);
|
|
545
|
+
const decrypted = await cryptoApi.subtle.decrypt(
|
|
546
|
+
{
|
|
547
|
+
name: "AES-GCM",
|
|
548
|
+
iv: toArrayBuffer(iv),
|
|
549
|
+
...associatedData ? { additionalData: toArrayBuffer(new TextEncoder().encode(associatedData)) } : {}
|
|
550
|
+
},
|
|
551
|
+
key,
|
|
552
|
+
toArrayBuffer(ciphertext)
|
|
553
|
+
);
|
|
554
|
+
return new TextDecoder().decode(decrypted);
|
|
555
|
+
} catch {
|
|
556
|
+
return null;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
async function deriveEmailEncryptionKeyB64(email, emailSaltB64) {
|
|
560
|
+
const encoder = new TextEncoder();
|
|
561
|
+
const emailBytes = encoder.encode(email);
|
|
562
|
+
const saltBytes = base64ToBytes(emailSaltB64);
|
|
563
|
+
const combined = new Uint8Array(emailBytes.length + saltBytes.length);
|
|
564
|
+
combined.set(emailBytes);
|
|
565
|
+
combined.set(saltBytes, emailBytes.length);
|
|
566
|
+
const hashBuffer = await cryptoApi.subtle.digest("SHA-256", toArrayBuffer(combined));
|
|
567
|
+
return bytesToBase64(new Uint8Array(hashBuffer));
|
|
568
|
+
}
|
|
569
|
+
async function decryptBytesWithAesGcm(encryptedWithIvB64, rawKeyBytes) {
|
|
570
|
+
try {
|
|
571
|
+
const combined = base64ToBytes(encryptedWithIvB64);
|
|
572
|
+
if (combined.length <= AES_GCM_IV_LENGTH) {
|
|
573
|
+
return null;
|
|
574
|
+
}
|
|
575
|
+
let offset = 0;
|
|
576
|
+
if (combined.length > CIPHERTEXT_HEADER_LENGTH + AES_GCM_IV_LENGTH && combined[0] === CIPHERTEXT_MAGIC_0 && combined[1] === CIPHERTEXT_MAGIC_1) {
|
|
577
|
+
offset = CIPHERTEXT_HEADER_LENGTH;
|
|
578
|
+
}
|
|
579
|
+
const iv = combined.slice(offset, offset + AES_GCM_IV_LENGTH);
|
|
580
|
+
const ciphertext = combined.slice(offset + AES_GCM_IV_LENGTH);
|
|
581
|
+
const key = await cryptoApi.subtle.importKey(
|
|
582
|
+
"raw",
|
|
583
|
+
toArrayBuffer(rawKeyBytes),
|
|
584
|
+
{ name: "AES-GCM" },
|
|
585
|
+
false,
|
|
586
|
+
["decrypt"]
|
|
587
|
+
);
|
|
588
|
+
const decrypted = await cryptoApi.subtle.decrypt(
|
|
589
|
+
{ name: "AES-GCM", iv: toArrayBuffer(iv) },
|
|
590
|
+
key,
|
|
591
|
+
toArrayBuffer(ciphertext)
|
|
592
|
+
);
|
|
593
|
+
return new Uint8Array(decrypted);
|
|
594
|
+
} catch {
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
async function deriveEmbedKeyFromChatKey(chatKey, embedId) {
|
|
599
|
+
const hkdfKey = await cryptoApi.subtle.importKey(
|
|
600
|
+
"raw",
|
|
601
|
+
toArrayBuffer(new Uint8Array(chatKey)),
|
|
602
|
+
"HKDF",
|
|
603
|
+
false,
|
|
604
|
+
["deriveBits"]
|
|
605
|
+
);
|
|
606
|
+
const salt = new TextEncoder().encode("openmates-embed-key-v1");
|
|
607
|
+
const info = new TextEncoder().encode(embedId);
|
|
608
|
+
const derivedBits = await cryptoApi.subtle.deriveBits(
|
|
609
|
+
{ name: "HKDF", hash: "SHA-256", salt: toArrayBuffer(salt), info: toArrayBuffer(info) },
|
|
610
|
+
hkdfKey,
|
|
611
|
+
256
|
|
612
|
+
);
|
|
613
|
+
return new Uint8Array(derivedBits);
|
|
614
|
+
}
|
|
615
|
+
async function encryptBytesWithAesGcm(data, rawKeyBytes) {
|
|
616
|
+
const iv = cryptoApi.getRandomValues(new Uint8Array(AES_GCM_IV_LENGTH));
|
|
617
|
+
const key = await cryptoApi.subtle.importKey(
|
|
618
|
+
"raw",
|
|
619
|
+
toArrayBuffer(rawKeyBytes),
|
|
620
|
+
{ name: "AES-GCM" },
|
|
621
|
+
false,
|
|
622
|
+
["encrypt"]
|
|
623
|
+
);
|
|
624
|
+
const encrypted = await cryptoApi.subtle.encrypt(
|
|
625
|
+
{ name: "AES-GCM", iv: toArrayBuffer(iv) },
|
|
626
|
+
key,
|
|
627
|
+
toArrayBuffer(data)
|
|
628
|
+
);
|
|
629
|
+
const cipherBytes = new Uint8Array(encrypted);
|
|
630
|
+
const combined = new Uint8Array(iv.length + cipherBytes.length);
|
|
631
|
+
combined.set(iv);
|
|
632
|
+
combined.set(cipherBytes, iv.length);
|
|
633
|
+
return bytesToBase64(combined);
|
|
634
|
+
}
|
|
635
|
+
async function encryptWithAesGcmCombined(plaintext, rawKeyBytes, associatedData) {
|
|
636
|
+
const iv = cryptoApi.getRandomValues(new Uint8Array(AES_GCM_IV_LENGTH));
|
|
637
|
+
const key = await cryptoApi.subtle.importKey(
|
|
638
|
+
"raw",
|
|
639
|
+
toArrayBuffer(rawKeyBytes),
|
|
640
|
+
{ name: "AES-GCM" },
|
|
641
|
+
false,
|
|
642
|
+
["encrypt"]
|
|
643
|
+
);
|
|
644
|
+
const encrypted = await cryptoApi.subtle.encrypt(
|
|
645
|
+
{
|
|
646
|
+
name: "AES-GCM",
|
|
647
|
+
iv: toArrayBuffer(iv),
|
|
648
|
+
...associatedData ? { additionalData: toArrayBuffer(new TextEncoder().encode(associatedData)) } : {}
|
|
649
|
+
},
|
|
650
|
+
key,
|
|
651
|
+
new TextEncoder().encode(plaintext)
|
|
652
|
+
);
|
|
653
|
+
const cipherBytes = new Uint8Array(encrypted);
|
|
654
|
+
const combined = new Uint8Array(iv.length + cipherBytes.length);
|
|
655
|
+
combined.set(iv);
|
|
656
|
+
combined.set(cipherBytes, iv.length);
|
|
657
|
+
return bytesToBase64(combined);
|
|
658
|
+
}
|
|
659
|
+
function hashItemKey(appId, itemKey) {
|
|
660
|
+
const raw = `${appId}-${itemKey}-${Date.now()}`;
|
|
661
|
+
return createHash("sha256").update(raw).digest("hex").slice(0, 32);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// src/objectSlugs.ts
|
|
665
|
+
import { createHmac as createHmac2, hkdfSync } from "crypto";
|
|
666
|
+
var SLUG_LOOKUP_HASH_INFO = "openmates-object-slug-index-v1";
|
|
667
|
+
var MAX_SLUG_LENGTH = 80;
|
|
668
|
+
function normalizeObjectSlug(value) {
|
|
669
|
+
const normalized = value.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/'/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-");
|
|
670
|
+
const slug = normalized.slice(0, MAX_SLUG_LENGTH).replace(/-+$/g, "");
|
|
671
|
+
if (!slug) throw new Error("Object slug must contain at least one letter or number.");
|
|
672
|
+
return slug;
|
|
673
|
+
}
|
|
674
|
+
async function buildEncryptedObjectSlugMetadata(input) {
|
|
675
|
+
const slug = normalizeObjectSlug(input.value);
|
|
676
|
+
return {
|
|
677
|
+
slug,
|
|
678
|
+
encrypted_slug: await encryptWithAesGcmCombined(slug, input.encryptionKey),
|
|
679
|
+
slug_lookup_hash: objectSlugLookupHash(slug, input.lookupKey)
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
async function decryptObjectSlug(encryptedSlug, encryptionKey) {
|
|
683
|
+
if (!encryptedSlug) return "";
|
|
684
|
+
return await decryptWithAesGcmCombined(encryptedSlug, encryptionKey) ?? "";
|
|
685
|
+
}
|
|
686
|
+
function objectSlugMatches(slug, query) {
|
|
687
|
+
if (!slug) return false;
|
|
688
|
+
try {
|
|
689
|
+
return normalizeObjectSlug(slug) === normalizeObjectSlug(query);
|
|
690
|
+
} catch {
|
|
691
|
+
return false;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
function objectSlugLookupHash(slug, lookupKey) {
|
|
695
|
+
const indexKey = Buffer.from(
|
|
696
|
+
hkdfSync("sha256", Buffer.from(lookupKey), Buffer.alloc(0), SLUG_LOOKUP_HASH_INFO, 32)
|
|
697
|
+
);
|
|
698
|
+
return createHmac2("sha256", indexKey).update(slug).digest("hex");
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// src/tasksCli.ts
|
|
702
|
+
var TASK_STATUSES = ["backlog", "todo", "in_progress", "blocked", "done"];
|
|
703
|
+
var DEFAULT_STANDALONE_PREFIX = "TASK";
|
|
704
|
+
var PRIORITY_LEVELS = ["none", "low", "medium", "high", "urgent"];
|
|
705
|
+
var LABEL_INDEX_INFO = "openmates-task-label-index-v1";
|
|
706
|
+
var EXTERNAL_CHAT_INDEX_INFO = "openmates-task-external-chat-index-v1";
|
|
707
|
+
var EXTERNAL_CHAT_PROVIDERS = ["codex"];
|
|
708
|
+
var BLOCKED_REASON_CODES = [
|
|
709
|
+
"needs_user_input",
|
|
710
|
+
"waiting_for_approval",
|
|
711
|
+
"missing_credentials",
|
|
712
|
+
"ambiguous_requirement",
|
|
713
|
+
"external_dependency",
|
|
714
|
+
"environment_unavailable",
|
|
715
|
+
"verification_failed",
|
|
716
|
+
"other"
|
|
717
|
+
];
|
|
718
|
+
function taskLookupScopes(scope) {
|
|
719
|
+
return TASK_STATUSES.map((status) => ({ ...scope, status }));
|
|
720
|
+
}
|
|
721
|
+
function taskEditLookupScope(scope) {
|
|
722
|
+
return { teamId: scope.teamId, personal: scope.personal };
|
|
723
|
+
}
|
|
724
|
+
function workflowProjectionDeleteGuidance(task) {
|
|
725
|
+
const workflowId = task.workflowId ?? "<workflow-id>";
|
|
726
|
+
const lines = [`Task ${task.shortId} is a read-only workflow projection, not an ordinary task.`];
|
|
727
|
+
if (task.projectionKind === "next_run" && task.canDelete !== false) {
|
|
728
|
+
lines.push(`To skip this scheduled run, retry: openmates tasks delete ${task.shortId} --confirm`);
|
|
729
|
+
} else if (task.workflowRunId && task.canCancel) {
|
|
730
|
+
lines.push(`To cancel this run: openmates workflows run-cancel ${workflowId} ${task.workflowRunId}`);
|
|
731
|
+
}
|
|
732
|
+
lines.push(`To deactivate the full workflow: openmates workflows disable ${workflowId}`);
|
|
733
|
+
return lines.join("\n");
|
|
734
|
+
}
|
|
735
|
+
function parseExternalChatRef(value) {
|
|
736
|
+
const separator = value.indexOf(":");
|
|
737
|
+
const provider = value.slice(0, separator).trim().toLowerCase();
|
|
738
|
+
const id = value.slice(separator + 1).trim();
|
|
739
|
+
if (separator <= 0 || !id) throw new Error("--external-chat requires provider:id.");
|
|
740
|
+
if (!EXTERNAL_CHAT_PROVIDERS.includes(provider)) throw new Error(`Unsupported external chat provider '${provider}'. Use codex.`);
|
|
741
|
+
return { provider, id };
|
|
742
|
+
}
|
|
743
|
+
function externalChatLookupHash(masterKey, context) {
|
|
744
|
+
const indexKey = Buffer.from(hkdfSync2("sha256", Buffer.from(masterKey), Buffer.alloc(0), EXTERNAL_CHAT_INDEX_INFO, 32));
|
|
745
|
+
return createHmac3("sha256", indexKey).update(`${context.provider}\0${context.id}`).digest("hex");
|
|
746
|
+
}
|
|
747
|
+
function normalizeBlockedReasonCode(value) {
|
|
748
|
+
const normalized = value.trim().toLowerCase().replace(/[\s-]+/g, "_");
|
|
749
|
+
if (!BLOCKED_REASON_CODES.includes(normalized)) {
|
|
750
|
+
throw new Error(`Unknown blocked reason code '${value}'. Expected one of: ${BLOCKED_REASON_CODES.join(", ")}`);
|
|
751
|
+
}
|
|
752
|
+
return normalized;
|
|
753
|
+
}
|
|
754
|
+
function normalizeTaskStatus(value) {
|
|
755
|
+
if (value === void 0) return void 0;
|
|
756
|
+
if (TASK_STATUSES.includes(value)) return value;
|
|
757
|
+
throw new Error(`Unknown task status '${value}'. Expected one of: ${TASK_STATUSES.join(", ")}`);
|
|
758
|
+
}
|
|
759
|
+
function parseAssignee(value) {
|
|
760
|
+
const normalized = value?.trim().toLowerCase().replace(/-/g, "_");
|
|
761
|
+
if (!normalized || normalized === "user") return { assigneeType: "user", assigneeIdentity: null, assigneeHash: null };
|
|
762
|
+
if (normalized === "openmates") return { assigneeType: "openmates", assigneeIdentity: "openmates", assigneeHash: null };
|
|
763
|
+
if (normalized === "external_ai" || normalized === "codex") return { assigneeType: "external_ai", assigneeIdentity: "codex", assigneeHash: null };
|
|
764
|
+
if (normalized === "unassigned") return { assigneeType: "unassigned", assigneeIdentity: null, assigneeHash: null };
|
|
765
|
+
return { assigneeType: "user", assigneeIdentity: null, assigneeHash: value };
|
|
766
|
+
}
|
|
767
|
+
function splitCsvFlag(value) {
|
|
768
|
+
if (typeof value !== "string") return [];
|
|
769
|
+
return value.split(/[,\n]/).map((item) => item.trim()).filter(Boolean);
|
|
770
|
+
}
|
|
771
|
+
function parseDueAt(value) {
|
|
772
|
+
if (value === void 0) return void 0;
|
|
773
|
+
if (value === false || value === true) throw new Error("--due requires a timestamp or date value.");
|
|
774
|
+
const numeric = Number(value);
|
|
775
|
+
if (Number.isFinite(numeric) && numeric > 0) return Math.floor(numeric);
|
|
776
|
+
const parsed = Date.parse(value);
|
|
777
|
+
if (Number.isNaN(parsed)) throw new Error(`Invalid --due value '${value}'.`);
|
|
778
|
+
return Math.floor(parsed / 1e3);
|
|
779
|
+
}
|
|
780
|
+
function normalizeLabels(labels) {
|
|
781
|
+
const output = [];
|
|
782
|
+
const seen = /* @__PURE__ */ new Set();
|
|
783
|
+
for (const label of labels ?? []) {
|
|
784
|
+
const normalized = label.trim().toLowerCase().replace(/\s+/g, " ");
|
|
785
|
+
if (!normalized || seen.has(normalized)) continue;
|
|
786
|
+
seen.add(normalized);
|
|
787
|
+
output.push(normalized);
|
|
788
|
+
}
|
|
789
|
+
return output;
|
|
790
|
+
}
|
|
791
|
+
function labelHashes(masterKey, labels) {
|
|
792
|
+
const indexKey = Buffer.from(hkdfSync2("sha256", Buffer.from(masterKey), Buffer.alloc(0), LABEL_INDEX_INFO, 32));
|
|
793
|
+
return normalizeLabels(labels).map((label) => createHmac3("sha256", indexKey).update(label).digest("hex"));
|
|
794
|
+
}
|
|
795
|
+
function normalizeTaskPriority(value) {
|
|
796
|
+
if (value === void 0) return void 0;
|
|
797
|
+
if (value === null) return 0;
|
|
798
|
+
if (typeof value === "number") {
|
|
799
|
+
if (!Number.isInteger(value) || value < 0 || value > 4) throw new Error(`Invalid task priority '${value}'.`);
|
|
800
|
+
return value;
|
|
801
|
+
}
|
|
802
|
+
const normalized = value.trim().toLowerCase();
|
|
803
|
+
const index = PRIORITY_LEVELS.indexOf(normalized);
|
|
804
|
+
if (index < 0) throw new Error(`Unknown task priority '${value}'. Expected one of: ${PRIORITY_LEVELS.join(", ")}`);
|
|
805
|
+
return index;
|
|
806
|
+
}
|
|
807
|
+
function taskPriorityLevel(priority) {
|
|
808
|
+
return PRIORITY_LEVELS[Math.max(0, Math.min(4, Math.trunc(priority ?? 0)))] ?? "none";
|
|
809
|
+
}
|
|
810
|
+
async function buildCreateUserTaskInput(masterKey, input) {
|
|
811
|
+
if (input.chatId && input.externalChat) throw new Error("A task cannot use both native chat and external chat context.");
|
|
812
|
+
const taskKey = randomBytes(32);
|
|
813
|
+
const encryptedTaskKey = await encryptBytesWithAesGcm(taskKey, masterKey);
|
|
814
|
+
const timestamp = nowSeconds();
|
|
815
|
+
const assignee = parseAssignee(input.assign);
|
|
816
|
+
const linkedProjectIds = input.projectIds ?? [];
|
|
817
|
+
const labels = normalizeLabels(input.labels ?? input.tags ?? []);
|
|
818
|
+
const status = input.status ?? "todo";
|
|
819
|
+
const slugMetadata = await buildEncryptedObjectSlugMetadata({
|
|
820
|
+
value: input.slug ?? input.title,
|
|
821
|
+
encryptionKey: taskKey,
|
|
822
|
+
lookupKey: masterKey
|
|
823
|
+
});
|
|
824
|
+
return {
|
|
825
|
+
task_id: input.taskId ?? randomUUIDCompat(),
|
|
826
|
+
short_id: void 0,
|
|
827
|
+
version: 1,
|
|
828
|
+
encrypted_task_key: encryptedTaskKey,
|
|
829
|
+
encrypted_slug: slugMetadata.encrypted_slug,
|
|
830
|
+
slug_lookup_hash: slugMetadata.slug_lookup_hash,
|
|
831
|
+
encrypted_title: await encryptWithAesGcmCombined(input.title, taskKey),
|
|
832
|
+
encrypted_description: await encryptWithAesGcmCombined(input.description ?? "", taskKey),
|
|
833
|
+
encrypted_labels: await encryptWithAesGcmCombined(JSON.stringify(labels), taskKey),
|
|
834
|
+
encrypted_tags: await encryptWithAesGcmCombined(JSON.stringify(labels), taskKey),
|
|
835
|
+
label_hashes: labelHashes(masterKey, labels),
|
|
836
|
+
encrypted_linked_project_ids: await encryptWithAesGcmCombined(JSON.stringify(linkedProjectIds), taskKey),
|
|
837
|
+
status,
|
|
838
|
+
assignee_type: assignee.assigneeType,
|
|
839
|
+
assignee_identity: assignee.assigneeIdentity,
|
|
840
|
+
assignee_hash: assignee.assigneeHash,
|
|
841
|
+
primary_chat_id: input.chatId ?? null,
|
|
842
|
+
...input.externalChat ? {
|
|
843
|
+
external_chat_provider: input.externalChat.provider,
|
|
844
|
+
external_chat_lookup_hash: externalChatLookupHash(masterKey, input.externalChat),
|
|
845
|
+
encrypted_external_chat_id: await encryptWithAesGcmCombined(input.externalChat.id, taskKey),
|
|
846
|
+
encrypted_external_chat_title: await encryptWithAesGcmCombined(input.externalChat.title ?? "", taskKey)
|
|
847
|
+
} : {},
|
|
848
|
+
linked_project_ids: linkedProjectIds,
|
|
849
|
+
plan_id: input.planId ?? null,
|
|
850
|
+
due_at: input.dueAt ?? null,
|
|
851
|
+
priority: normalizeTaskPriority(input.priority) ?? 0,
|
|
852
|
+
position: timestamp,
|
|
853
|
+
created_at: timestamp,
|
|
854
|
+
updated_at: timestamp,
|
|
855
|
+
plaintext_title: assignee.assigneeType === "openmates" ? input.title : void 0,
|
|
856
|
+
plaintext_description: assignee.assigneeType === "openmates" ? input.description : void 0
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
async function buildUpdateUserTaskInput(task, masterKey, input, projectContext) {
|
|
860
|
+
if (input.chatId && input.externalChat) throw new Error("A task cannot use both native chat and external chat context.");
|
|
861
|
+
const taskKey = await taskKeyFromRecord(task.encrypted, masterKey);
|
|
862
|
+
const patch = { version: task.version, updated_at: nowSeconds() };
|
|
863
|
+
if (input.title !== void 0) patch.encrypted_title = await encryptWithAesGcmCombined(input.title, taskKey);
|
|
864
|
+
if (input.description !== void 0) patch.encrypted_description = await encryptWithAesGcmCombined(input.description, taskKey);
|
|
865
|
+
if (input.slug !== void 0) {
|
|
866
|
+
const slugMetadata = await buildEncryptedObjectSlugMetadata({
|
|
867
|
+
value: input.slug,
|
|
868
|
+
encryptionKey: taskKey,
|
|
869
|
+
lookupKey: masterKey
|
|
870
|
+
});
|
|
871
|
+
patch.encrypted_slug = slugMetadata.encrypted_slug;
|
|
872
|
+
patch.slug_lookup_hash = slugMetadata.slug_lookup_hash;
|
|
873
|
+
}
|
|
874
|
+
if (input.status !== void 0) patch.status = input.status;
|
|
875
|
+
if (input.assign !== void 0) {
|
|
876
|
+
const assignee = parseAssignee(input.assign);
|
|
877
|
+
patch.assignee_type = assignee.assigneeType;
|
|
878
|
+
patch.assignee_identity = assignee.assigneeIdentity;
|
|
879
|
+
patch.assignee_hash = assignee.assigneeHash;
|
|
880
|
+
}
|
|
881
|
+
if (input.chatId !== void 0) {
|
|
882
|
+
patch.primary_chat_id = input.chatId;
|
|
883
|
+
patch.external_chat_provider = null;
|
|
884
|
+
patch.external_chat_lookup_hash = null;
|
|
885
|
+
patch.encrypted_external_chat_id = null;
|
|
886
|
+
patch.encrypted_external_chat_title = null;
|
|
887
|
+
}
|
|
888
|
+
if (input.externalChat) {
|
|
889
|
+
patch.primary_chat_id = null;
|
|
890
|
+
patch.external_chat_provider = input.externalChat.provider;
|
|
891
|
+
patch.external_chat_lookup_hash = externalChatLookupHash(masterKey, input.externalChat);
|
|
892
|
+
patch.encrypted_external_chat_id = await encryptWithAesGcmCombined(input.externalChat.id, taskKey);
|
|
893
|
+
patch.encrypted_external_chat_title = await encryptWithAesGcmCombined(input.externalChat.title ?? "", taskKey);
|
|
894
|
+
}
|
|
895
|
+
if (input.projectIds !== void 0) {
|
|
896
|
+
if (!projectContext) throw new Error("Project relinking requires task wrappers and authorized project keys.");
|
|
897
|
+
const projectIds = [...new Set(input.projectIds)];
|
|
898
|
+
const hashId = (id) => createHash2("sha256").update(id).digest("hex");
|
|
899
|
+
const chatId = input.externalChat ? null : input.chatId !== void 0 ? input.chatId : task.primaryChatId;
|
|
900
|
+
const planId = input.planId !== void 0 ? input.planId : task.planId;
|
|
901
|
+
const wrappers = [{
|
|
902
|
+
key_type: "master",
|
|
903
|
+
encrypted_task_key: task.encrypted.encrypted_task_key,
|
|
904
|
+
created_at: nowSeconds()
|
|
905
|
+
}];
|
|
906
|
+
for (const wrapper of projectContext.keyWrappers) {
|
|
907
|
+
if (wrapper.key_type === "master" || wrapper.key_type === "project") continue;
|
|
908
|
+
if (wrapper.key_type === "chat" && (!chatId || wrapper.hashed_chat_id !== hashId(chatId))) continue;
|
|
909
|
+
if (wrapper.key_type === "plan" && (!planId || wrapper.hashed_plan_id !== hashId(planId))) continue;
|
|
910
|
+
if (!["chat", "plan", "team"].includes(String(wrapper.key_type))) throw new Error("Unsupported task key wrapper type.");
|
|
911
|
+
if (typeof wrapper.encrypted_task_key !== "string" || !wrapper.encrypted_task_key) throw new Error("Missing encrypted task wrapper key.");
|
|
912
|
+
const retained = {};
|
|
913
|
+
for (const field of ["key_type", "encrypted_task_key", "hashed_chat_id", "hashed_plan_id", "hashed_team_id", "team_key_epoch", "created_at", "expires_at"]) {
|
|
914
|
+
if (wrapper[field] !== void 0) retained[field] = wrapper[field];
|
|
915
|
+
}
|
|
916
|
+
wrappers.push(retained);
|
|
917
|
+
}
|
|
918
|
+
if (chatId && !wrappers.some((wrapper) => wrapper.key_type === "chat")) throw new Error("Missing required primary chat key wrapper.");
|
|
919
|
+
if (planId && !wrappers.some((wrapper) => wrapper.key_type === "plan")) throw new Error("Missing required plan key wrapper.");
|
|
920
|
+
for (const projectId of projectIds) {
|
|
921
|
+
const projectKey = projectContext.projectKeys.get(projectId);
|
|
922
|
+
if (!projectKey) throw new Error(`Missing authorized project key for ${projectId}.`);
|
|
923
|
+
wrappers.push({
|
|
924
|
+
key_type: "project",
|
|
925
|
+
hashed_project_id: hashId(projectId),
|
|
926
|
+
encrypted_task_key: await encryptBytesWithAesGcm(taskKey, projectKey),
|
|
927
|
+
created_at: nowSeconds()
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
patch.key_wrappers = wrappers;
|
|
931
|
+
patch.linked_project_ids = projectIds;
|
|
932
|
+
patch.encrypted_linked_project_ids = await encryptWithAesGcmCombined(JSON.stringify(projectIds), taskKey);
|
|
933
|
+
}
|
|
934
|
+
if (input.planId !== void 0) patch.plan_id = input.planId;
|
|
935
|
+
const priority = normalizeTaskPriority(input.priority);
|
|
936
|
+
if (priority !== void 0) patch.priority = priority;
|
|
937
|
+
const replaceLabels = input.labels ?? input.tags;
|
|
938
|
+
if (replaceLabels !== void 0 || input.addLabels !== void 0 || input.addTags !== void 0 || input.removeLabels !== void 0 || input.removeTags !== void 0) {
|
|
939
|
+
const remove = new Set(normalizeLabels([...input.removeLabels ?? [], ...input.removeTags ?? []]));
|
|
940
|
+
const base = replaceLabels !== void 0 ? normalizeLabels(replaceLabels) : task.labels;
|
|
941
|
+
const labels = normalizeLabels([...base.filter((label) => !remove.has(label)), ...input.addLabels ?? [], ...input.addTags ?? []]);
|
|
942
|
+
patch.encrypted_labels = await encryptWithAesGcmCombined(JSON.stringify(labels), taskKey);
|
|
943
|
+
patch.encrypted_tags = await encryptWithAesGcmCombined(JSON.stringify(labels), taskKey);
|
|
944
|
+
patch.label_hashes = labelHashes(masterKey, labels);
|
|
945
|
+
}
|
|
946
|
+
return patch;
|
|
947
|
+
}
|
|
948
|
+
async function buildCreateTaskActivityInput(task, masterKey, input) {
|
|
949
|
+
const taskKey = await taskKeyFromRecord(task.encrypted, masterKey);
|
|
950
|
+
const entryId = input.entryId ?? randomUUIDCompat();
|
|
951
|
+
return {
|
|
952
|
+
entry_id: entryId,
|
|
953
|
+
encrypted_message: await encryptWithAesGcmCombined(input.message, taskKey, `task_activity_comment:${task.taskId}:${entryId}:v1`),
|
|
954
|
+
...input.embedKeyMaterial ? { encrypted_embed_key_material: await encryptWithAesGcmCombined(input.embedKeyMaterial, taskKey, `task_activity_embed_keys:${task.taskId}:${entryId}:v1`) } : {},
|
|
955
|
+
embed_refs: input.embedRefs ?? [],
|
|
956
|
+
created_at: input.createdAt ?? nowSeconds()
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
async function decryptTaskActivityEntry(task, masterKey, record) {
|
|
960
|
+
const base = {
|
|
961
|
+
entryId: record.entry_id,
|
|
962
|
+
taskId: record.task_id,
|
|
963
|
+
kind: record.kind,
|
|
964
|
+
actorType: record.actor_type,
|
|
965
|
+
actorHash: record.actor_hash,
|
|
966
|
+
actorIdentity: record.actor_identity ?? null,
|
|
967
|
+
actorDisplayName: record.actor_display_name ?? null,
|
|
968
|
+
actorProfileImageUrl: record.actor_profile_image_url ?? null,
|
|
969
|
+
authorHash: record.author_hash ?? record.actor_hash ?? null,
|
|
970
|
+
eventType: record.event_type,
|
|
971
|
+
sourceSurface: record.source_surface,
|
|
972
|
+
previousStatus: record.previous_status ?? null,
|
|
973
|
+
nextStatus: record.next_status ?? null,
|
|
974
|
+
createdAt: record.created_at,
|
|
975
|
+
deletedAt: record.deleted_at ?? null,
|
|
976
|
+
deletedByHash: record.deleted_by_hash ?? null,
|
|
977
|
+
deletedByDisplayName: record.deleted_by_display_name ?? null,
|
|
978
|
+
embedRefs: record.kind === "tombstone" ? [] : record.embed_refs
|
|
979
|
+
};
|
|
980
|
+
if (record.kind === "tombstone") return base;
|
|
981
|
+
if (record.kind !== "comment") return base;
|
|
982
|
+
if (!record.encrypted_message) {
|
|
983
|
+
throw new Error(`Task Activity entry ${record.entry_id} is missing encrypted content.`);
|
|
984
|
+
}
|
|
985
|
+
const taskKey = await taskKeyFromRecord(task.encrypted, masterKey);
|
|
986
|
+
const message = await decryptWithAesGcmCombined(record.encrypted_message, taskKey, `task_activity_comment:${task.taskId}:${record.entry_id}:v1`);
|
|
987
|
+
if (message === null) throw new Error(`Failed to decrypt Task Activity entry ${record.entry_id}.`);
|
|
988
|
+
const embedKeyMaterial = record.encrypted_embed_key_material ? await decryptWithAesGcmCombined(record.encrypted_embed_key_material, taskKey, `task_activity_embed_keys:${task.taskId}:${record.entry_id}:v1`) : null;
|
|
989
|
+
if (record.encrypted_embed_key_material && embedKeyMaterial === null) {
|
|
990
|
+
throw new Error(`Failed to decrypt Task Activity embed key material ${record.entry_id}.`);
|
|
991
|
+
}
|
|
992
|
+
return { ...base, message, ...embedKeyMaterial !== null ? { embedKeyMaterial } : {} };
|
|
993
|
+
}
|
|
994
|
+
async function decryptTaskActivityEntries(task, masterKey, records) {
|
|
995
|
+
const entries = [];
|
|
996
|
+
for (const record of records) entries.push(await decryptTaskActivityEntry(task, masterKey, record));
|
|
997
|
+
return entries;
|
|
998
|
+
}
|
|
999
|
+
async function decryptUserTask(record, masterKey) {
|
|
1000
|
+
if (record.source === "workflow_run") return workflowProjectionToTask(record);
|
|
1001
|
+
if (typeof record.version !== "number") throw new Error(`Task ${record.task_id} is missing version.`);
|
|
1002
|
+
const taskKey = await taskKeyFromRecord(record, masterKey);
|
|
1003
|
+
const labels = parseStringArray(await decryptOptional(record.encrypted_labels || record.encrypted_tags, taskKey));
|
|
1004
|
+
const linkedProjectIds = parseStringArray(await decryptOptional(record.encrypted_linked_project_ids, taskKey));
|
|
1005
|
+
const externalChat = record.external_chat_provider && record.encrypted_external_chat_id ? {
|
|
1006
|
+
provider: record.external_chat_provider,
|
|
1007
|
+
id: await decryptOptional(record.encrypted_external_chat_id, taskKey),
|
|
1008
|
+
title: await decryptOptional(record.encrypted_external_chat_title, taskKey)
|
|
1009
|
+
} : null;
|
|
1010
|
+
return {
|
|
1011
|
+
taskId: record.task_id,
|
|
1012
|
+
shortId: record.short_id || deriveShortId(record),
|
|
1013
|
+
slug: await decryptObjectSlug(record.encrypted_slug, taskKey),
|
|
1014
|
+
title: await decryptOptional(record.encrypted_title, taskKey) || "(untitled task)",
|
|
1015
|
+
description: await decryptOptional(record.encrypted_description, taskKey),
|
|
1016
|
+
labels,
|
|
1017
|
+
tags: labels,
|
|
1018
|
+
latestInstruction: await decryptOptional(record.encrypted_latest_instruction, taskKey),
|
|
1019
|
+
status: record.status,
|
|
1020
|
+
assigneeType: record.assignee_type,
|
|
1021
|
+
assigneeIdentity: record.assignee_identity ?? null,
|
|
1022
|
+
assigneeHash: record.assignee_hash ?? null,
|
|
1023
|
+
primaryChatId: record.primary_chat_id ?? null,
|
|
1024
|
+
externalChat,
|
|
1025
|
+
linkedProjectIds: linkedProjectIds.length > 0 ? linkedProjectIds : record.linked_project_ids ?? [],
|
|
1026
|
+
planId: record.plan_id ?? null,
|
|
1027
|
+
dueAt: record.due_at ?? null,
|
|
1028
|
+
priority: record.priority ?? 0,
|
|
1029
|
+
priorityLevel: taskPriorityLevel(record.priority),
|
|
1030
|
+
position: record.position ?? 0,
|
|
1031
|
+
queueState: record.queue_state ?? "none",
|
|
1032
|
+
blockedReasonCode: record.blocked_reason_code ?? null,
|
|
1033
|
+
blockedReason: await decryptOptional(record.encrypted_blocked_reason, taskKey),
|
|
1034
|
+
aiExecutionState: record.ai_execution_state ?? null,
|
|
1035
|
+
version: record.version,
|
|
1036
|
+
encrypted: record
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
function workflowProjectionToTask(record) {
|
|
1040
|
+
const blockedReason = record.blocked_reason_code ?? record.blocked_reason ?? null;
|
|
1041
|
+
return {
|
|
1042
|
+
taskId: record.task_id,
|
|
1043
|
+
source: "workflow_run",
|
|
1044
|
+
projectionKind: record.projection_kind ?? null,
|
|
1045
|
+
workflowId: record.workflow_id ?? null,
|
|
1046
|
+
workflowRunId: record.workflow_run_id ?? null,
|
|
1047
|
+
triggerId: record.trigger_id ?? null,
|
|
1048
|
+
canCancel: Boolean(record.can_cancel),
|
|
1049
|
+
canDelete: Boolean(record.can_delete),
|
|
1050
|
+
shortId: record.short_id || workflowProjectionShortId(record),
|
|
1051
|
+
slug: "",
|
|
1052
|
+
title: record.title || "Workflow run",
|
|
1053
|
+
description: record.blocked_message ?? "",
|
|
1054
|
+
labels: [],
|
|
1055
|
+
tags: [],
|
|
1056
|
+
latestInstruction: "",
|
|
1057
|
+
status: record.status,
|
|
1058
|
+
assigneeType: "user",
|
|
1059
|
+
assigneeIdentity: null,
|
|
1060
|
+
assigneeHash: null,
|
|
1061
|
+
primaryChatId: null,
|
|
1062
|
+
externalChat: null,
|
|
1063
|
+
linkedProjectIds: [],
|
|
1064
|
+
planId: null,
|
|
1065
|
+
dueAt: record.due_at ?? null,
|
|
1066
|
+
priority: record.priority ?? 0,
|
|
1067
|
+
priorityLevel: taskPriorityLevel(record.priority),
|
|
1068
|
+
position: record.position ?? 0,
|
|
1069
|
+
queueState: String(record.run_status ?? "workflow"),
|
|
1070
|
+
blockedReasonCode: blockedReason,
|
|
1071
|
+
blockedReason: record.blocked_message ?? "",
|
|
1072
|
+
aiExecutionState: null,
|
|
1073
|
+
readOnly: true,
|
|
1074
|
+
version: typeof record.version === "number" ? record.version : 1,
|
|
1075
|
+
encrypted: record
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
async function buildBlockUserTaskInput(task, masterKey, input) {
|
|
1079
|
+
const reasonCode = normalizeBlockedReasonCode(input.reasonCode);
|
|
1080
|
+
const encryptedBlockedReason = input.reasonText === void 0 ? void 0 : await encryptWithAesGcmCombined(input.reasonText, await taskKeyFromRecord(task.encrypted, masterKey));
|
|
1081
|
+
return {
|
|
1082
|
+
version: task.version,
|
|
1083
|
+
blocked_reason_code: reasonCode,
|
|
1084
|
+
...encryptedBlockedReason ? { encrypted_blocked_reason: encryptedBlockedReason } : {}
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
function workflowProjectionShortId(record) {
|
|
1088
|
+
const stableId = record.workflow_run_id || record.task_id;
|
|
1089
|
+
return `WF-${createHash2("sha256").update(stableId).digest("hex").slice(0, 6).toUpperCase()}`;
|
|
1090
|
+
}
|
|
1091
|
+
async function decryptUserTasks(records, masterKey) {
|
|
1092
|
+
const output = [];
|
|
1093
|
+
for (const record of records) output.push(await decryptUserTask(record, masterKey));
|
|
1094
|
+
return output;
|
|
1095
|
+
}
|
|
1096
|
+
async function decryptUserTasksForCli(records, masterKey, warn) {
|
|
1097
|
+
const output = [];
|
|
1098
|
+
for (const record of records) {
|
|
1099
|
+
try {
|
|
1100
|
+
output.push(await decryptUserTask(record, masterKey));
|
|
1101
|
+
} catch {
|
|
1102
|
+
const identifier = record.short_id || record.task_id || "unknown";
|
|
1103
|
+
warn(`Warning: skipped undecryptable task ${identifier} (${record.task_id || "unknown"}).`);
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
return output;
|
|
1107
|
+
}
|
|
1108
|
+
function findTask(tasks, id) {
|
|
1109
|
+
const taskIdMatch = tasks.find((candidate) => candidate.taskId === id);
|
|
1110
|
+
if (taskIdMatch) return taskIdMatch;
|
|
1111
|
+
const slugMatches = tasks.filter((candidate) => objectSlugMatches(candidate.slug, id));
|
|
1112
|
+
if (slugMatches.length > 1) throw new Error(`Task slug '${id}' is ambiguous in the current task list. Use the full task ID.`);
|
|
1113
|
+
if (slugMatches.length === 1) return slugMatches[0];
|
|
1114
|
+
const shortIdMatches = tasks.filter((candidate) => candidate.shortId === id);
|
|
1115
|
+
if (shortIdMatches.length > 1) throw new Error(`Task '${id}' is ambiguous in the current task list. Use the full task ID.`);
|
|
1116
|
+
const task = shortIdMatches[0];
|
|
1117
|
+
if (!task) throw new Error(`Task '${id}' was not found in the current task list.`);
|
|
1118
|
+
return task;
|
|
1119
|
+
}
|
|
1120
|
+
function renderTaskList(tasks) {
|
|
1121
|
+
if (tasks.length === 0) return "No tasks found.";
|
|
1122
|
+
const lines = ["Tasks", "Handle Status Priority Labels Title"];
|
|
1123
|
+
for (const task of tasks) {
|
|
1124
|
+
lines.push(`${pad(taskHandle(task), 19)} ${pad(task.status, 12)} ${pad(task.priorityLevel, 9)} ${pad(task.labels.join(","), 19)} ${task.title}`);
|
|
1125
|
+
}
|
|
1126
|
+
return lines.join("\n");
|
|
1127
|
+
}
|
|
1128
|
+
function renderTaskDetail(task) {
|
|
1129
|
+
const lines = [
|
|
1130
|
+
`Task ${taskHandle(task)}`,
|
|
1131
|
+
`Title: ${task.title}`,
|
|
1132
|
+
...task.slug ? [`Slug: ${task.slug}`] : [],
|
|
1133
|
+
`Status: ${task.status}`,
|
|
1134
|
+
`Priority: ${task.priorityLevel}`,
|
|
1135
|
+
`Assignee: ${assigneeLabel(task)}`,
|
|
1136
|
+
`Queue: ${task.queueState}`,
|
|
1137
|
+
`Task ID: ${task.taskId}`
|
|
1138
|
+
];
|
|
1139
|
+
if (task.description) lines.push(`Description: ${task.description}`);
|
|
1140
|
+
if (task.labels.length > 0) lines.push(`Labels: ${task.labels.join(", ")}`);
|
|
1141
|
+
if (task.primaryChatId) lines.push(`Chat: ${task.primaryChatId}`);
|
|
1142
|
+
if (task.externalChat) lines.push(`External chat: ${task.externalChat.provider}:${task.externalChat.id}${task.externalChat.title ? ` (${task.externalChat.title})` : ""}`);
|
|
1143
|
+
if (task.linkedProjectIds.length > 0) lines.push(`Projects: ${task.linkedProjectIds.join(", ")}`);
|
|
1144
|
+
if (task.planId) lines.push(`Plan: ${task.planId}`);
|
|
1145
|
+
if (task.blockedReasonCode) lines.push(`Blocked reason: ${task.blockedReasonCode}`);
|
|
1146
|
+
if (task.blockedReason) lines.push(`Blocked explanation: ${task.blockedReason}`);
|
|
1147
|
+
if (task.aiExecutionState) lines.push(`AI state: ${task.aiExecutionState}`);
|
|
1148
|
+
return lines.join("\n");
|
|
1149
|
+
}
|
|
1150
|
+
function renderTaskActivityList(entries) {
|
|
1151
|
+
if (entries.length === 0) return "No Activity yet.";
|
|
1152
|
+
const lines = ["Activity"];
|
|
1153
|
+
for (const entry of entries) {
|
|
1154
|
+
const actor = entry.actorType === "user" ? entry.actorDisplayName ?? "User" : taskIdentityDisplayName(entry.actorIdentity) ?? (entry.actorType === "system" ? "System" : "External AI");
|
|
1155
|
+
const source = entry.sourceSurface === "cli" ? " via OpenMates CLI" : entry.sourceSurface === "sdk_npm" || entry.sourceSurface === "sdk_pip" ? " via OpenMates SDK" : "";
|
|
1156
|
+
if (entry.kind === "tombstone") {
|
|
1157
|
+
const deleter = entry.deletedByDisplayName ?? actor;
|
|
1158
|
+
lines.push(`[${formatActivityTime(entry.deletedAt ?? entry.createdAt)}] Comment by ${actor} deleted by ${deleter}`);
|
|
1159
|
+
} else if (entry.kind === "comment") {
|
|
1160
|
+
lines.push(`[${formatActivityTime(entry.createdAt)}] ${actor}${source}`);
|
|
1161
|
+
lines.push(entry.message ?? "");
|
|
1162
|
+
} else {
|
|
1163
|
+
const transition = entry.eventType === "status" && entry.nextStatus ? `changed status${entry.previousStatus ? ` from ${entry.previousStatus}` : ""} to ${entry.nextStatus}` : entry.eventType.replaceAll("_", " ");
|
|
1164
|
+
lines.push(`[${formatActivityTime(entry.createdAt)}] ${actor} ${transition}`);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
return lines.join("\n");
|
|
1168
|
+
}
|
|
1169
|
+
function taskIdentityDisplayName(identity) {
|
|
1170
|
+
if (identity === "openmates") return "OpenMates";
|
|
1171
|
+
if (identity === "codex") return "Codex";
|
|
1172
|
+
return null;
|
|
1173
|
+
}
|
|
1174
|
+
function renderTaskBoard(tasks, width = process.stdout.columns || 100) {
|
|
1175
|
+
const columns = TASK_STATUSES.map((status) => ({ status, tasks: tasks.filter((task) => task.status === status).sort(compareTasks) }));
|
|
1176
|
+
if (width < 96) {
|
|
1177
|
+
const lines2 = ["OpenMates Tasks Board"];
|
|
1178
|
+
for (const column of columns) {
|
|
1179
|
+
lines2.push("", `${columnTitle(column.status)} (${column.tasks.length})`, "-".repeat(24));
|
|
1180
|
+
lines2.push(...boardColumnLines(column.tasks, 8));
|
|
1181
|
+
}
|
|
1182
|
+
return lines2.join("\n");
|
|
1183
|
+
}
|
|
1184
|
+
const perColumn = 6;
|
|
1185
|
+
const columnWidth = 22;
|
|
1186
|
+
const lines = ["OpenMates Tasks Board", ""];
|
|
1187
|
+
lines.push(columns.map((column) => pad(`${columnTitle(column.status)} (${column.tasks.length})`, columnWidth)).join(" "));
|
|
1188
|
+
lines.push(columns.map(() => "-".repeat(columnWidth)).join(" "));
|
|
1189
|
+
const renderedColumns = columns.map((column) => boardColumnLines(column.tasks, perColumn).map((line) => truncate(line, columnWidth)));
|
|
1190
|
+
const maxRows = Math.max(...renderedColumns.map((column) => column.length));
|
|
1191
|
+
for (let row = 0; row < maxRows; row += 1) {
|
|
1192
|
+
lines.push(renderedColumns.map((column) => pad(column[row] ?? "", columnWidth)).join(" "));
|
|
1193
|
+
}
|
|
1194
|
+
return lines.join("\n");
|
|
1195
|
+
}
|
|
1196
|
+
function boardColumnLines(tasks, limit) {
|
|
1197
|
+
if (tasks.length === 0) return ["No tasks here."];
|
|
1198
|
+
const visible = tasks.slice(0, limit).flatMap((task) => [
|
|
1199
|
+
`[${taskHandle(task)}] ${task.title}`,
|
|
1200
|
+
` ${assigneeLabel(task)} ${task.queueState === "none" ? "" : task.queueState}`.trimEnd()
|
|
1201
|
+
]);
|
|
1202
|
+
if (tasks.length > limit) visible.push(`... ${tasks.length - limit} more`);
|
|
1203
|
+
return visible;
|
|
1204
|
+
}
|
|
1205
|
+
function taskHandle(task) {
|
|
1206
|
+
return task.slug || task.shortId;
|
|
1207
|
+
}
|
|
1208
|
+
function compareTasks(a, b) {
|
|
1209
|
+
return a.position - b.position || a.title.localeCompare(b.title);
|
|
1210
|
+
}
|
|
1211
|
+
function columnTitle(status) {
|
|
1212
|
+
if (status === "in_progress") return "In progress";
|
|
1213
|
+
return status.slice(0, 1).toUpperCase() + status.slice(1).replace("_", " ");
|
|
1214
|
+
}
|
|
1215
|
+
function assigneeLabel(task) {
|
|
1216
|
+
return taskIdentityDisplayName(task.assigneeIdentity) ?? (task.assigneeType === "unassigned" ? "unassigned" : task.assigneeHash ?? "user");
|
|
1217
|
+
}
|
|
1218
|
+
async function taskKeyFromRecord(record, masterKey) {
|
|
1219
|
+
if (!record.encrypted_task_key) throw new Error(`Task ${record.task_id} is missing encrypted task key.`);
|
|
1220
|
+
const taskKey = await decryptBytesWithAesGcm(record.encrypted_task_key, masterKey);
|
|
1221
|
+
if (!taskKey) throw new Error(`Failed to decrypt task key for ${record.task_id}.`);
|
|
1222
|
+
return taskKey;
|
|
1223
|
+
}
|
|
1224
|
+
async function decryptOptional(value, key) {
|
|
1225
|
+
if (!value) return "";
|
|
1226
|
+
return await decryptWithAesGcmCombined(value, key) ?? "";
|
|
1227
|
+
}
|
|
1228
|
+
function parseStringArray(value) {
|
|
1229
|
+
if (!value) return [];
|
|
1230
|
+
try {
|
|
1231
|
+
const parsed = JSON.parse(value);
|
|
1232
|
+
return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string") : [];
|
|
1233
|
+
} catch {
|
|
1234
|
+
return [];
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
function deriveShortId(record) {
|
|
1238
|
+
const prefix = record.short_id_prefix || DEFAULT_STANDALONE_PREFIX;
|
|
1239
|
+
const source = record.task_id || `${record.created_at ?? ""}-${record.position ?? ""}`;
|
|
1240
|
+
const digest = createHash2("sha256").update(source).digest("hex").slice(0, 4).toUpperCase();
|
|
1241
|
+
return `${prefix}-${parseInt(digest, 16) % 1e4}`;
|
|
1242
|
+
}
|
|
1243
|
+
function nowSeconds() {
|
|
1244
|
+
return Math.floor(Date.now() / 1e3);
|
|
1245
|
+
}
|
|
1246
|
+
function formatActivityTime(value) {
|
|
1247
|
+
return new Date(value * 1e3).toISOString();
|
|
1248
|
+
}
|
|
1249
|
+
function randomUUIDCompat() {
|
|
1250
|
+
return randomUUID();
|
|
1251
|
+
}
|
|
1252
|
+
function pad(value, length) {
|
|
1253
|
+
return truncate(value, length).padEnd(length);
|
|
1254
|
+
}
|
|
1255
|
+
function truncate(value, length) {
|
|
1256
|
+
return value.length <= length ? value : `${value.slice(0, Math.max(0, length - 3))}...`;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
export {
|
|
1260
|
+
base64ToBytes,
|
|
1261
|
+
bytesToBase64,
|
|
1262
|
+
bytesToBase64Url,
|
|
1263
|
+
deriveTeamInviteKey,
|
|
1264
|
+
deriveChatCompletionRecoveryKeypair,
|
|
1265
|
+
openChatCompletionRecoveryEnvelope,
|
|
1266
|
+
openRecoveryOutputEnvelope,
|
|
1267
|
+
hashEmail,
|
|
1268
|
+
hashKey,
|
|
1269
|
+
derivePasswordKeysV2,
|
|
1270
|
+
passwordProofV2,
|
|
1271
|
+
unwrapPasswordMasterKey,
|
|
1272
|
+
createPasswordMigrationMaterialV2,
|
|
1273
|
+
createSignupCryptoMaterial,
|
|
1274
|
+
createRecoveryKeyMaterial,
|
|
1275
|
+
splitApiKeyCredential,
|
|
1276
|
+
createApiKeyCryptoMaterial,
|
|
1277
|
+
unwrapApiKeyMasterKey,
|
|
1278
|
+
decryptWithAesGcmCombined,
|
|
1279
|
+
deriveEmailEncryptionKeyB64,
|
|
1280
|
+
decryptBytesWithAesGcm,
|
|
1281
|
+
deriveEmbedKeyFromChatKey,
|
|
1282
|
+
encryptBytesWithAesGcm,
|
|
1283
|
+
encryptWithAesGcmCombined,
|
|
1284
|
+
hashItemKey,
|
|
1285
|
+
normalizeObjectSlug,
|
|
1286
|
+
buildEncryptedObjectSlugMetadata,
|
|
1287
|
+
decryptObjectSlug,
|
|
1288
|
+
objectSlugMatches,
|
|
1289
|
+
TASK_STATUSES,
|
|
1290
|
+
taskLookupScopes,
|
|
1291
|
+
taskEditLookupScope,
|
|
1292
|
+
workflowProjectionDeleteGuidance,
|
|
1293
|
+
parseExternalChatRef,
|
|
1294
|
+
externalChatLookupHash,
|
|
1295
|
+
normalizeBlockedReasonCode,
|
|
1296
|
+
normalizeTaskStatus,
|
|
1297
|
+
parseAssignee,
|
|
1298
|
+
splitCsvFlag,
|
|
1299
|
+
parseDueAt,
|
|
1300
|
+
normalizeLabels,
|
|
1301
|
+
labelHashes,
|
|
1302
|
+
normalizeTaskPriority,
|
|
1303
|
+
taskPriorityLevel,
|
|
1304
|
+
buildCreateUserTaskInput,
|
|
1305
|
+
buildUpdateUserTaskInput,
|
|
1306
|
+
buildCreateTaskActivityInput,
|
|
1307
|
+
decryptTaskActivityEntry,
|
|
1308
|
+
decryptTaskActivityEntries,
|
|
1309
|
+
decryptUserTask,
|
|
1310
|
+
buildBlockUserTaskInput,
|
|
1311
|
+
decryptUserTasks,
|
|
1312
|
+
decryptUserTasksForCli,
|
|
1313
|
+
findTask,
|
|
1314
|
+
renderTaskList,
|
|
1315
|
+
renderTaskDetail,
|
|
1316
|
+
renderTaskActivityList,
|
|
1317
|
+
taskIdentityDisplayName,
|
|
1318
|
+
renderTaskBoard,
|
|
1319
|
+
taskKeyFromRecord
|
|
1320
|
+
};
|