@mdbase-dev/connect 0.1.0-beta.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/LICENSE +21 -0
- package/README.md +405 -0
- package/dist/async.d.ts +1 -0
- package/dist/async.js +13 -0
- package/dist/authorization-types.d.ts +7 -0
- package/dist/authorization-types.js +1 -0
- package/dist/authorization-url.d.ts +3 -0
- package/dist/authorization-url.js +27 -0
- package/dist/base64.d.ts +3 -0
- package/dist/base64.js +21 -0
- package/dist/browser/integrity.json +4 -0
- package/dist/browser/mdbase-connect.min.js +3 -0
- package/dist/browser/mdbase-connect.min.js.sha384 +1 -0
- package/dist/collection-client.d.ts +57 -0
- package/dist/collection-client.js +214 -0
- package/dist/connect.d.ts +39 -0
- package/dist/connect.js +620 -0
- package/dist/connection-notifications.d.ts +25 -0
- package/dist/connection-notifications.js +179 -0
- package/dist/connection-transport.d.ts +80 -0
- package/dist/connection-transport.js +670 -0
- package/dist/connection-types.d.ts +29 -0
- package/dist/connection-types.js +1 -0
- package/dist/connection.d.ts +154 -0
- package/dist/connection.js +371 -0
- package/dist/crypto.d.ts +64 -0
- package/dist/crypto.js +391 -0
- package/dist/errors.d.ts +31 -0
- package/dist/errors.js +83 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +12 -0
- package/dist/internal-types.d.ts +70 -0
- package/dist/internal-types.js +1 -0
- package/dist/notifications.d.ts +47 -0
- package/dist/notifications.js +52 -0
- package/dist/operation-helpers.d.ts +24 -0
- package/dist/operation-helpers.js +129 -0
- package/dist/operation-types.d.ts +254 -0
- package/dist/operation-types.js +1 -0
- package/dist/runtime-utils.d.ts +33 -0
- package/dist/runtime-utils.js +224 -0
- package/dist/selection.d.ts +48 -0
- package/dist/selection.js +112 -0
- package/dist/session.d.ts +76 -0
- package/dist/session.js +271 -0
- package/package.json +41 -0
package/dist/crypto.js
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import { ENCRYPTED_RELAY_PROTOCOL_VERSION, AUTHORITY_PROOF_DOMAIN, AUTHORITY_PROOF_HEADERS, AUTHORITY_PROOF_VERSION, RELAY_ENCRYPTION_SUITE } from "@mdbase-dev/connect-protocol";
|
|
2
|
+
const DATABASE_NAME = "mdbase-connect-keys";
|
|
3
|
+
const STORE_NAME = "grant-keys";
|
|
4
|
+
const MAX_U64 = 18446744073709551615n;
|
|
5
|
+
const REQUEST_INFO = "mdbase-connect relay request key v1";
|
|
6
|
+
const RESPONSE_INFO = "mdbase-connect relay response key v1";
|
|
7
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
8
|
+
/** Origin-scoped storage for non-extractable grant keys and atomic counters. */
|
|
9
|
+
export class IndexedDbGrantKeyStore {
|
|
10
|
+
database = null;
|
|
11
|
+
async create(handle) {
|
|
12
|
+
const record = await generateGrantKey(handle);
|
|
13
|
+
const database = await this.open();
|
|
14
|
+
await idbWrite(database, (store) => store.add({ ...record, counter: "0" }));
|
|
15
|
+
return record;
|
|
16
|
+
}
|
|
17
|
+
async get(handle) {
|
|
18
|
+
const database = await this.open();
|
|
19
|
+
const value = await idbRequest(database.transaction(STORE_NAME).objectStore(STORE_NAME).get(handle));
|
|
20
|
+
return value ? {
|
|
21
|
+
handle: value.handle,
|
|
22
|
+
agreementPrivateKey: value.agreementPrivateKey,
|
|
23
|
+
agreementPublicKey: value.agreementPublicKey,
|
|
24
|
+
signingPrivateKey: value.signingPrivateKey,
|
|
25
|
+
signingPublicKey: value.signingPublicKey
|
|
26
|
+
} : null;
|
|
27
|
+
}
|
|
28
|
+
async nextCounter(handle) {
|
|
29
|
+
const database = await this.open();
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
const transaction = database.transaction(STORE_NAME, "readwrite");
|
|
32
|
+
const store = transaction.objectStore(STORE_NAME);
|
|
33
|
+
const request = store.get(handle);
|
|
34
|
+
let next = null;
|
|
35
|
+
request.onerror = () => reject(request.error);
|
|
36
|
+
request.onsuccess = () => {
|
|
37
|
+
const record = request.result;
|
|
38
|
+
if (!record) {
|
|
39
|
+
transaction.abort();
|
|
40
|
+
reject(new RelayCryptoError("missing_grant_key", "The encrypted grant key is unavailable."));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const counter = BigInt(record.counter) + 1n;
|
|
44
|
+
if (counter > MAX_U64) {
|
|
45
|
+
transaction.abort();
|
|
46
|
+
reject(new RelayCryptoError("counter_exhausted", "The encrypted grant must be authorized again."));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
next = counter.toString();
|
|
50
|
+
store.put({ ...record, counter: next });
|
|
51
|
+
};
|
|
52
|
+
transaction.onerror = () => reject(transaction.error);
|
|
53
|
+
transaction.onabort = () => reject(transaction.error ?? new Error("Key counter transaction aborted."));
|
|
54
|
+
transaction.oncomplete = () => {
|
|
55
|
+
if (next)
|
|
56
|
+
resolve(next);
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
async delete(handle) {
|
|
61
|
+
const database = await this.open();
|
|
62
|
+
await idbWrite(database, (store) => store.delete(handle));
|
|
63
|
+
}
|
|
64
|
+
open() {
|
|
65
|
+
if (typeof indexedDB === "undefined") {
|
|
66
|
+
throw new RelayCryptoError("key_storage_unavailable", "Encrypted relay authorization requires browser IndexedDB or an injected grant key store.");
|
|
67
|
+
}
|
|
68
|
+
if (!this.database) {
|
|
69
|
+
this.database = new Promise((resolve, reject) => {
|
|
70
|
+
const request = indexedDB.open(DATABASE_NAME, 1);
|
|
71
|
+
request.onupgradeneeded = () => {
|
|
72
|
+
if (!request.result.objectStoreNames.contains(STORE_NAME)) {
|
|
73
|
+
request.result.createObjectStore(STORE_NAME, { keyPath: "handle" });
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
request.onerror = () => reject(request.error);
|
|
77
|
+
request.onsuccess = () => resolve(request.result);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return this.database;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** Deterministic process-local storage for tests, CLIs, and non-browser adapters. */
|
|
84
|
+
export class MemoryGrantKeyStore {
|
|
85
|
+
records = new Map();
|
|
86
|
+
sequence = Promise.resolve();
|
|
87
|
+
async create(handle) {
|
|
88
|
+
if (this.records.has(handle))
|
|
89
|
+
throw new Error(`Grant key already exists: ${handle}`);
|
|
90
|
+
const record = await generateGrantKey(handle);
|
|
91
|
+
this.records.set(handle, { ...record, counter: "0" });
|
|
92
|
+
return record;
|
|
93
|
+
}
|
|
94
|
+
async get(handle) {
|
|
95
|
+
const value = this.records.get(handle);
|
|
96
|
+
return value ? {
|
|
97
|
+
handle: value.handle,
|
|
98
|
+
agreementPrivateKey: value.agreementPrivateKey,
|
|
99
|
+
agreementPublicKey: value.agreementPublicKey,
|
|
100
|
+
signingPrivateKey: value.signingPrivateKey,
|
|
101
|
+
signingPublicKey: value.signingPublicKey
|
|
102
|
+
} : null;
|
|
103
|
+
}
|
|
104
|
+
nextCounter(handle) {
|
|
105
|
+
let resolve;
|
|
106
|
+
let reject;
|
|
107
|
+
const result = new Promise((res, rej) => { resolve = res; reject = rej; });
|
|
108
|
+
this.sequence = this.sequence.then(() => {
|
|
109
|
+
const record = this.records.get(handle);
|
|
110
|
+
if (!record)
|
|
111
|
+
throw new RelayCryptoError("missing_grant_key", "The encrypted grant key is unavailable.");
|
|
112
|
+
const counter = BigInt(record.counter) + 1n;
|
|
113
|
+
if (counter > MAX_U64)
|
|
114
|
+
throw new RelayCryptoError("counter_exhausted", "The encrypted grant must be authorized again.");
|
|
115
|
+
record.counter = counter.toString();
|
|
116
|
+
resolve(record.counter);
|
|
117
|
+
}).catch((error) => {
|
|
118
|
+
reject(error);
|
|
119
|
+
});
|
|
120
|
+
return result;
|
|
121
|
+
}
|
|
122
|
+
async delete(handle) {
|
|
123
|
+
this.records.delete(handle);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** Sign one remote-authority or token-refresh request with the approved grant key. */
|
|
127
|
+
export async function signAuthorityRequest(store, handle, expectedPublicKey, input) {
|
|
128
|
+
const record = await requireSigningKey(store, handle, expectedPublicKey);
|
|
129
|
+
const timestamp = input.timestamp ?? Math.floor(Date.now() / 1_000);
|
|
130
|
+
const nonce = input.nonce ?? crypto.randomUUID();
|
|
131
|
+
const message = await authorityProofMessage({ ...input, timestamp, nonce });
|
|
132
|
+
const signature = await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, record.signingPrivateKey, new TextEncoder().encode(message));
|
|
133
|
+
return {
|
|
134
|
+
[AUTHORITY_PROOF_HEADERS.version]: String(AUTHORITY_PROOF_VERSION),
|
|
135
|
+
[AUTHORITY_PROOF_HEADERS.timestamp]: String(timestamp),
|
|
136
|
+
[AUTHORITY_PROOF_HEADERS.nonce]: nonce,
|
|
137
|
+
[AUTHORITY_PROOF_HEADERS.signature]: bytesToBase64Url(new Uint8Array(signature))
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
export async function authorityProofMessage(input) {
|
|
141
|
+
const method = input.method.toUpperCase();
|
|
142
|
+
const bodyHash = await sha256Base64Url(input.body ?? "");
|
|
143
|
+
const credentialHash = await sha256Base64Url(input.credential);
|
|
144
|
+
for (const value of [method, input.target, String(input.timestamp), input.nonce]) {
|
|
145
|
+
if (!value || value.includes("\n") || value.includes("\r")) {
|
|
146
|
+
throw new RelayCryptoError("invalid_proof_input", "Authority request proof metadata is invalid.");
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return [
|
|
150
|
+
AUTHORITY_PROOF_DOMAIN,
|
|
151
|
+
AUTHORITY_PROOF_VERSION,
|
|
152
|
+
method,
|
|
153
|
+
input.target,
|
|
154
|
+
bodyHash,
|
|
155
|
+
credentialHash,
|
|
156
|
+
input.timestamp,
|
|
157
|
+
input.nonce
|
|
158
|
+
].join("\n");
|
|
159
|
+
}
|
|
160
|
+
export async function encryptRelayRequest(store, handle, binding, operation, input, requestId = crypto.randomUUID()) {
|
|
161
|
+
validateGrantEncryption(binding.encryption);
|
|
162
|
+
const record = await requireAgreementKey(store, handle, binding.encryption.application_agreement_public_key);
|
|
163
|
+
const counter = await store.nextCounter(handle);
|
|
164
|
+
const metadata = { binding, requestId, operation, counter };
|
|
165
|
+
const key = await deriveDirectionalKey(record.agreementPrivateKey, binding, "request");
|
|
166
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(input ?? {}));
|
|
167
|
+
const ciphertext = await crypto.subtle.encrypt({
|
|
168
|
+
name: "AES-GCM",
|
|
169
|
+
iv: nonce(counter),
|
|
170
|
+
additionalData: new TextEncoder().encode(aad(metadata, "request")),
|
|
171
|
+
tagLength: 128
|
|
172
|
+
}, key, plaintext);
|
|
173
|
+
return {
|
|
174
|
+
type: "encrypted_operation_request",
|
|
175
|
+
...envelopeMetadata(metadata),
|
|
176
|
+
ciphertext: bytesToBase64Url(new Uint8Array(ciphertext))
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
export async function decryptRelayResponse(store, handle, binding, request, response) {
|
|
180
|
+
if (!sameEnvelopeMetadata(request, response)) {
|
|
181
|
+
throw new RelayCryptoError("invalid_encrypted_response", "Encrypted response metadata does not match its request.");
|
|
182
|
+
}
|
|
183
|
+
const record = await requireAgreementKey(store, handle, binding.encryption.application_agreement_public_key);
|
|
184
|
+
const metadata = {
|
|
185
|
+
binding,
|
|
186
|
+
requestId: request.request_id,
|
|
187
|
+
operation: request.operation,
|
|
188
|
+
counter: request.counter
|
|
189
|
+
};
|
|
190
|
+
const key = await deriveDirectionalKey(record.agreementPrivateKey, binding, "response");
|
|
191
|
+
try {
|
|
192
|
+
const plaintext = await crypto.subtle.decrypt({
|
|
193
|
+
name: "AES-GCM",
|
|
194
|
+
iv: nonce(request.counter),
|
|
195
|
+
additionalData: new TextEncoder().encode(aad(metadata, "response")),
|
|
196
|
+
tagLength: 128
|
|
197
|
+
}, key, toArrayBuffer(base64UrlToBytes(response.ciphertext)));
|
|
198
|
+
const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(plaintext));
|
|
199
|
+
if (value?.ok === true && "result" in value)
|
|
200
|
+
return value;
|
|
201
|
+
if (value?.ok === false
|
|
202
|
+
&& typeof value.error?.code === "string"
|
|
203
|
+
&& typeof value.error?.message === "string")
|
|
204
|
+
return value;
|
|
205
|
+
throw new Error("invalid response body");
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
if (error instanceof RelayCryptoError)
|
|
209
|
+
throw error;
|
|
210
|
+
throw new RelayCryptoError("encrypted_response_rejected", "The encrypted connector response could not be authenticated.");
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
export class RelayCryptoError extends Error {
|
|
214
|
+
code;
|
|
215
|
+
constructor(code, message) {
|
|
216
|
+
super(message);
|
|
217
|
+
this.code = code;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
async function generateGrantKey(handle) {
|
|
221
|
+
const agreement = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
|
|
222
|
+
const agreementPublicKey = bytesToBase64Url(new Uint8Array(await crypto.subtle.exportKey("raw", agreement.publicKey)));
|
|
223
|
+
const agreementPkcs8 = await crypto.subtle.exportKey("pkcs8", agreement.privateKey);
|
|
224
|
+
const agreementPrivateKey = await crypto.subtle.importKey("pkcs8", agreementPkcs8, { name: "ECDH", namedCurve: "P-256" }, false, ["deriveBits"]);
|
|
225
|
+
const signing = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]);
|
|
226
|
+
const signingPublicKey = bytesToBase64Url(new Uint8Array(await crypto.subtle.exportKey("raw", signing.publicKey)));
|
|
227
|
+
const signingPkcs8 = await crypto.subtle.exportKey("pkcs8", signing.privateKey);
|
|
228
|
+
const signingPrivateKey = await crypto.subtle.importKey("pkcs8", signingPkcs8, { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"]);
|
|
229
|
+
return {
|
|
230
|
+
handle,
|
|
231
|
+
agreementPrivateKey,
|
|
232
|
+
agreementPublicKey,
|
|
233
|
+
signingPrivateKey,
|
|
234
|
+
signingPublicKey
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
async function requireAgreementKey(store, handle, expectedPublicKey) {
|
|
238
|
+
const record = await store.get(handle);
|
|
239
|
+
if (!record || record.agreementPublicKey !== expectedPublicKey) {
|
|
240
|
+
throw new RelayCryptoError("missing_grant_key", "The encrypted grant key is unavailable or does not match the grant.");
|
|
241
|
+
}
|
|
242
|
+
return record;
|
|
243
|
+
}
|
|
244
|
+
async function requireSigningKey(store, handle, expectedPublicKey) {
|
|
245
|
+
const record = await store.get(handle);
|
|
246
|
+
if (!record || record.signingPublicKey !== expectedPublicKey) {
|
|
247
|
+
throw new RelayCryptoError("missing_grant_key", "The remote authority signing key is unavailable or does not match the grant.");
|
|
248
|
+
}
|
|
249
|
+
return record;
|
|
250
|
+
}
|
|
251
|
+
async function deriveDirectionalKey(privateKey, binding, direction) {
|
|
252
|
+
let peer;
|
|
253
|
+
let shared;
|
|
254
|
+
try {
|
|
255
|
+
peer = await crypto.subtle.importKey("raw", toArrayBuffer(p256PublicKey(binding.encryption.connector_agreement_public_key)), { name: "ECDH", namedCurve: "P-256" }, false, []);
|
|
256
|
+
shared = await crypto.subtle.deriveBits({ name: "ECDH", public: peer }, privateKey, 256);
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
throw new RelayCryptoError("invalid_public_key", "The connector relay public key is invalid.");
|
|
260
|
+
}
|
|
261
|
+
const material = await crypto.subtle.importKey("raw", shared, "HKDF", false, ["deriveKey"]);
|
|
262
|
+
const contextBytes = new TextEncoder().encode(context(binding));
|
|
263
|
+
const salt = await crypto.subtle.digest("SHA-256", contextBytes);
|
|
264
|
+
return crypto.subtle.deriveKey({
|
|
265
|
+
name: "HKDF",
|
|
266
|
+
hash: "SHA-256",
|
|
267
|
+
salt,
|
|
268
|
+
info: new TextEncoder().encode(direction === "request" ? REQUEST_INFO : RESPONSE_INFO)
|
|
269
|
+
}, material, { name: "AES-GCM", length: 256 }, false, direction === "request" ? ["encrypt"] : ["decrypt"]);
|
|
270
|
+
}
|
|
271
|
+
function context(binding) {
|
|
272
|
+
const value = binding.encryption;
|
|
273
|
+
return [
|
|
274
|
+
"mdbase-connect",
|
|
275
|
+
ENCRYPTED_RELAY_PROTOCOL_VERSION,
|
|
276
|
+
value.suite,
|
|
277
|
+
binding.grantId,
|
|
278
|
+
binding.applicationId,
|
|
279
|
+
value.connector_id,
|
|
280
|
+
value.collection_id,
|
|
281
|
+
value.scope_epoch,
|
|
282
|
+
value.key_id
|
|
283
|
+
].join("|");
|
|
284
|
+
}
|
|
285
|
+
function aad(metadata, direction) {
|
|
286
|
+
return [context(metadata.binding), metadata.requestId, direction, metadata.operation, metadata.counter].join("|");
|
|
287
|
+
}
|
|
288
|
+
function envelopeMetadata(metadata) {
|
|
289
|
+
const encryption = metadata.binding.encryption;
|
|
290
|
+
return {
|
|
291
|
+
protocol_version: ENCRYPTED_RELAY_PROTOCOL_VERSION,
|
|
292
|
+
suite: RELAY_ENCRYPTION_SUITE,
|
|
293
|
+
request_id: metadata.requestId,
|
|
294
|
+
grant_id: metadata.binding.grantId,
|
|
295
|
+
application_id: metadata.binding.applicationId,
|
|
296
|
+
connector_id: encryption.connector_id,
|
|
297
|
+
collection_id: encryption.collection_id,
|
|
298
|
+
operation: metadata.operation,
|
|
299
|
+
scope_epoch: encryption.scope_epoch,
|
|
300
|
+
key_id: encryption.key_id,
|
|
301
|
+
counter: metadata.counter
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
function sameEnvelopeMetadata(request, response) {
|
|
305
|
+
return response.type === "encrypted_operation_response"
|
|
306
|
+
&& typeof response.ciphertext === "string"
|
|
307
|
+
&& request.protocol_version === response.protocol_version
|
|
308
|
+
&& request.suite === response.suite
|
|
309
|
+
&& request.request_id === response.request_id
|
|
310
|
+
&& request.grant_id === response.grant_id
|
|
311
|
+
&& request.application_id === response.application_id
|
|
312
|
+
&& request.connector_id === response.connector_id
|
|
313
|
+
&& request.collection_id === response.collection_id
|
|
314
|
+
&& request.operation === response.operation
|
|
315
|
+
&& request.scope_epoch === response.scope_epoch
|
|
316
|
+
&& request.key_id === response.key_id
|
|
317
|
+
&& request.counter === response.counter;
|
|
318
|
+
}
|
|
319
|
+
export function validateGrantEncryption(encryption) {
|
|
320
|
+
if (encryption.protocol_version !== ENCRYPTED_RELAY_PROTOCOL_VERSION
|
|
321
|
+
|| encryption.suite !== RELAY_ENCRYPTION_SUITE
|
|
322
|
+
|| !Number.isSafeInteger(encryption.scope_epoch)
|
|
323
|
+
|| encryption.scope_epoch <= 0
|
|
324
|
+
|| typeof encryption.key_id !== "string"
|
|
325
|
+
|| encryption.key_id.length === 0
|
|
326
|
+
|| encryption.key_id.length > 200
|
|
327
|
+
|| encryption.key_id.includes("|")) {
|
|
328
|
+
throw new RelayCryptoError("unsupported_encryption", "The grant uses an unsupported relay encryption profile.");
|
|
329
|
+
}
|
|
330
|
+
if (typeof encryption.connector_id !== "string"
|
|
331
|
+
|| !UUID_PATTERN.test(encryption.connector_id)
|
|
332
|
+
|| typeof encryption.collection_id !== "string"
|
|
333
|
+
|| !UUID_PATTERN.test(encryption.collection_id)) {
|
|
334
|
+
throw new RelayCryptoError("unsupported_encryption", "The grant uses invalid encrypted relay identities.");
|
|
335
|
+
}
|
|
336
|
+
p256PublicKey(encryption.application_agreement_public_key);
|
|
337
|
+
p256PublicKey(encryption.connector_agreement_public_key);
|
|
338
|
+
}
|
|
339
|
+
function nonce(counter) {
|
|
340
|
+
const value = BigInt(counter);
|
|
341
|
+
if (value <= 0n || value > MAX_U64 || value.toString() !== counter) {
|
|
342
|
+
throw new RelayCryptoError("invalid_counter", "The encrypted relay counter is invalid.");
|
|
343
|
+
}
|
|
344
|
+
const result = new Uint8Array(new ArrayBuffer(12));
|
|
345
|
+
const view = new DataView(result.buffer);
|
|
346
|
+
view.setBigUint64(4, value, false);
|
|
347
|
+
return result;
|
|
348
|
+
}
|
|
349
|
+
function bytesToBase64Url(bytes) {
|
|
350
|
+
let binary = "";
|
|
351
|
+
for (const byte of bytes)
|
|
352
|
+
binary += String.fromCharCode(byte);
|
|
353
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
354
|
+
}
|
|
355
|
+
async function sha256Base64Url(value) {
|
|
356
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
|
357
|
+
return bytesToBase64Url(new Uint8Array(digest));
|
|
358
|
+
}
|
|
359
|
+
function base64UrlToBytes(value) {
|
|
360
|
+
if (!/^[A-Za-z0-9_-]+$/.test(value))
|
|
361
|
+
throw new RelayCryptoError("invalid_base64", "Encrypted relay data is malformed.");
|
|
362
|
+
const padding = "=".repeat((4 - value.length % 4) % 4);
|
|
363
|
+
const binary = atob(value.replaceAll("-", "+").replaceAll("_", "/") + padding);
|
|
364
|
+
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
365
|
+
}
|
|
366
|
+
function p256PublicKey(value) {
|
|
367
|
+
const bytes = base64UrlToBytes(value);
|
|
368
|
+
if (bytes.length !== 65 || bytes[0] !== 4) {
|
|
369
|
+
throw new RelayCryptoError("invalid_public_key", "Encrypted relay public keys must be uncompressed P-256 points.");
|
|
370
|
+
}
|
|
371
|
+
return bytes;
|
|
372
|
+
}
|
|
373
|
+
function toArrayBuffer(value) {
|
|
374
|
+
return Uint8Array.from(value).buffer;
|
|
375
|
+
}
|
|
376
|
+
function idbRequest(request) {
|
|
377
|
+
return new Promise((resolve, reject) => {
|
|
378
|
+
request.onerror = () => reject(request.error);
|
|
379
|
+
request.onsuccess = () => resolve(request.result);
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
function idbWrite(database, operation) {
|
|
383
|
+
return new Promise((resolve, reject) => {
|
|
384
|
+
const transaction = database.transaction(STORE_NAME, "readwrite");
|
|
385
|
+
const request = operation(transaction.objectStore(STORE_NAME));
|
|
386
|
+
request.onerror = () => reject(request.error);
|
|
387
|
+
transaction.onerror = () => reject(transaction.error);
|
|
388
|
+
transaction.onabort = () => reject(transaction.error ?? new Error("Grant key transaction aborted."));
|
|
389
|
+
transaction.oncomplete = () => resolve();
|
|
390
|
+
});
|
|
391
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { MdbaseDiagnostic, MdbaseOperationEnvelope } from "@mdbase-dev/connect-protocol";
|
|
2
|
+
export type MdbaseRecoveryAction = "retry" | "reauthorize" | "refresh" | "resolve_outcome" | "fix_request" | "none";
|
|
3
|
+
export interface MdbaseConnectErrorOptions {
|
|
4
|
+
status?: number;
|
|
5
|
+
retryable?: boolean;
|
|
6
|
+
requiresAuthorization?: boolean;
|
|
7
|
+
outcomeUnknown?: boolean;
|
|
8
|
+
recovery?: MdbaseRecoveryAction;
|
|
9
|
+
details?: unknown;
|
|
10
|
+
cause?: unknown;
|
|
11
|
+
}
|
|
12
|
+
export declare class MdbaseConnectError extends Error {
|
|
13
|
+
readonly code: string;
|
|
14
|
+
readonly status?: number;
|
|
15
|
+
readonly retryable: boolean;
|
|
16
|
+
readonly requiresAuthorization: boolean;
|
|
17
|
+
readonly outcomeUnknown: boolean;
|
|
18
|
+
readonly recovery: MdbaseRecoveryAction;
|
|
19
|
+
readonly details?: unknown;
|
|
20
|
+
constructor(code: string, message: string, options?: MdbaseConnectErrorOptions);
|
|
21
|
+
}
|
|
22
|
+
export declare class MdbaseOperationValidationError<Result = unknown> extends Error {
|
|
23
|
+
readonly diagnostics: MdbaseDiagnostic[];
|
|
24
|
+
readonly result: Result;
|
|
25
|
+
readonly code = "operation_invalid";
|
|
26
|
+
constructor(diagnostics: MdbaseDiagnostic[], result: Result);
|
|
27
|
+
}
|
|
28
|
+
/** Return a valid operation result or throw while preserving every diagnostic. */
|
|
29
|
+
export declare function unwrapOperation<Result>(envelope: MdbaseOperationEnvelope<Result>): Result;
|
|
30
|
+
/** True only when repeating a read/poll is safe without asking the user. */
|
|
31
|
+
export declare function isRetryableConnectError(error: unknown): boolean;
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export class MdbaseConnectError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
status;
|
|
4
|
+
retryable;
|
|
5
|
+
requiresAuthorization;
|
|
6
|
+
outcomeUnknown;
|
|
7
|
+
recovery;
|
|
8
|
+
details;
|
|
9
|
+
constructor(code, message, options = {}) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.name = "MdbaseConnectError";
|
|
13
|
+
const classification = classifyConnectError(code, options.status);
|
|
14
|
+
this.status = options.status;
|
|
15
|
+
this.retryable = options.retryable ?? classification.retryable;
|
|
16
|
+
this.requiresAuthorization = options.requiresAuthorization ?? classification.requiresAuthorization;
|
|
17
|
+
this.outcomeUnknown = options.outcomeUnknown ?? classification.outcomeUnknown;
|
|
18
|
+
this.recovery = options.recovery ?? classification.recovery;
|
|
19
|
+
this.details = options.details;
|
|
20
|
+
if (options.cause !== undefined)
|
|
21
|
+
Object.defineProperty(this, "cause", { value: options.cause, configurable: true });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export class MdbaseOperationValidationError extends Error {
|
|
25
|
+
diagnostics;
|
|
26
|
+
result;
|
|
27
|
+
code = "operation_invalid";
|
|
28
|
+
constructor(diagnostics, result) {
|
|
29
|
+
super(diagnostics.filter((item) => item.severity === "error").map((item) => item.message).join(" ")
|
|
30
|
+
|| diagnostics.map((item) => item.message).join(" ")
|
|
31
|
+
|| "The collection rejected this operation.");
|
|
32
|
+
this.diagnostics = diagnostics;
|
|
33
|
+
this.result = result;
|
|
34
|
+
this.name = "MdbaseOperationValidationError";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Return a valid operation result or throw while preserving every diagnostic. */
|
|
38
|
+
export function unwrapOperation(envelope) {
|
|
39
|
+
if (!envelope.valid)
|
|
40
|
+
throw new MdbaseOperationValidationError(envelope.diagnostics, envelope.result);
|
|
41
|
+
return envelope.result;
|
|
42
|
+
}
|
|
43
|
+
/** True only when repeating a read/poll is safe without asking the user. */
|
|
44
|
+
export function isRetryableConnectError(error) {
|
|
45
|
+
if (error instanceof MdbaseConnectError)
|
|
46
|
+
return error.retryable && !error.outcomeUnknown;
|
|
47
|
+
return error instanceof TypeError;
|
|
48
|
+
}
|
|
49
|
+
function classifyConnectError(code, status) {
|
|
50
|
+
const authorizationCodes = new Set([
|
|
51
|
+
"authorization_expired",
|
|
52
|
+
"direct_operation_rejected",
|
|
53
|
+
"encryption_required",
|
|
54
|
+
"insufficient_access",
|
|
55
|
+
"missing_grant_key",
|
|
56
|
+
"not_authorized",
|
|
57
|
+
"relay_authorization_expired"
|
|
58
|
+
]);
|
|
59
|
+
const outcomeUnknown = code === "direct_outcome_unknown" || code === "pending_mutation_unresolved";
|
|
60
|
+
const requiresAuthorization = authorizationCodes.has(code) || status === 401;
|
|
61
|
+
const retryableCodes = new Set([
|
|
62
|
+
"connector_offline",
|
|
63
|
+
"discovery_failed",
|
|
64
|
+
"relay_unavailable",
|
|
65
|
+
"sync_failed",
|
|
66
|
+
"temporarily_unavailable",
|
|
67
|
+
"timeout"
|
|
68
|
+
]);
|
|
69
|
+
const retryableStatus = status === 408 || status === 425 || status === 429 || (status !== undefined && status >= 500);
|
|
70
|
+
const retryable = !outcomeUnknown && !requiresAuthorization && (retryableCodes.has(code) || retryableStatus);
|
|
71
|
+
const recovery = outcomeUnknown
|
|
72
|
+
? "resolve_outcome"
|
|
73
|
+
: requiresAuthorization
|
|
74
|
+
? "reauthorize"
|
|
75
|
+
: code === "change_cursor_reset"
|
|
76
|
+
? "refresh"
|
|
77
|
+
: retryable
|
|
78
|
+
? "retry"
|
|
79
|
+
: status !== undefined && status >= 400 && status < 500
|
|
80
|
+
? "fix_request"
|
|
81
|
+
: "none";
|
|
82
|
+
return { retryable, requiresAuthorization, outcomeUnknown, recovery };
|
|
83
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { decryptRelayResponse, encryptRelayRequest, IndexedDbGrantKeyStore, MemoryGrantKeyStore, RelayCryptoError, type RelayBinding, type GrantKeyRecord, type GrantKeyStore } from "./crypto.js";
|
|
2
|
+
export * from "./authorization-types.js";
|
|
3
|
+
export * from "./collection-client.js";
|
|
4
|
+
export * from "./connect.js";
|
|
5
|
+
export * from "./connection.js";
|
|
6
|
+
export * from "./connection-types.js";
|
|
7
|
+
export * from "./errors.js";
|
|
8
|
+
export * from "./notifications.js";
|
|
9
|
+
export * from "./operation-types.js";
|
|
10
|
+
export * from "./selection.js";
|
|
11
|
+
export * from "./session.js";
|
|
12
|
+
export { createPkce } from "./runtime-utils.js";
|
|
13
|
+
export type { ApplicationProvisions, ApplicationRequirements, ApplicationNotifications, CollectionChange, CollectionChangesPage, CollectionContractDescriptor, CollectionDescription, CollectionFileMetadata, CollectionOperation as MdbaseOperation, CollectionTypeDescriptor, CollectionTypeDocument, ContractRequirement, DataContractViewIdentity, GrantScope, JsonObject, MdbaseAppManifest, NotificationCriterion, MdbaseDiagnostic, MdbaseOperationEnvelope, RecordDocument, QueryRecord, SavedNamedView, SavedViewDocument, SavedViewExecution, SavedViewList, SavedViewPresentation, SavedViewProperty, SavedViewSource, SavedViewSourceDocument, ReadViewSourceInput, CreateViewSourceInput, UpdateViewSourceInput, DeleteViewSourceInput, DeleteViewSourceResult, ExecuteViewInput, TypePackManifest, TypePackManifestResource, TypePackInstallResult, TypePackProvision, TypePackResourceDiff, TypePackSourceResource } from "@mdbase-dev/connect-protocol";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { decryptRelayResponse, encryptRelayRequest, IndexedDbGrantKeyStore, MemoryGrantKeyStore, RelayCryptoError } from "./crypto.js";
|
|
2
|
+
export * from "./authorization-types.js";
|
|
3
|
+
export * from "./collection-client.js";
|
|
4
|
+
export * from "./connect.js";
|
|
5
|
+
export * from "./connection.js";
|
|
6
|
+
export * from "./connection-types.js";
|
|
7
|
+
export * from "./errors.js";
|
|
8
|
+
export * from "./notifications.js";
|
|
9
|
+
export * from "./operation-types.js";
|
|
10
|
+
export * from "./selection.js";
|
|
11
|
+
export * from "./session.js";
|
|
12
|
+
export { createPkce } from "./runtime-utils.js";
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { ApplicationNotifications, CollectionOperation, EncryptedRelayOperationRequest, GrantEncryption, GrantScope } from "@mdbase-dev/connect-protocol";
|
|
2
|
+
import { encryptRelayRequest } from "./crypto.js";
|
|
3
|
+
export interface Application {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
distribution?: "web" | "portable";
|
|
7
|
+
homepage?: string;
|
|
8
|
+
project_url?: string;
|
|
9
|
+
notifications?: ApplicationNotifications;
|
|
10
|
+
}
|
|
11
|
+
export interface StoredAuthorization {
|
|
12
|
+
version: 1;
|
|
13
|
+
verifier: string;
|
|
14
|
+
state: string;
|
|
15
|
+
clientId: string;
|
|
16
|
+
redirectUri: string;
|
|
17
|
+
relayEncryption: "required" | "disabled";
|
|
18
|
+
collectionId?: string;
|
|
19
|
+
returnTo?: string;
|
|
20
|
+
keyHandle?: string;
|
|
21
|
+
applicationAgreementPublicKey?: string;
|
|
22
|
+
applicationSigningPublicKey?: string;
|
|
23
|
+
}
|
|
24
|
+
export interface StoredToken {
|
|
25
|
+
version: 1;
|
|
26
|
+
accessToken: string;
|
|
27
|
+
refreshToken?: string;
|
|
28
|
+
clientId: string;
|
|
29
|
+
collectionId: string;
|
|
30
|
+
collectionName: string;
|
|
31
|
+
operations: CollectionOperation[];
|
|
32
|
+
scope: GrantScope;
|
|
33
|
+
expiresAt: number;
|
|
34
|
+
refreshExpiresAt?: number;
|
|
35
|
+
grantId?: string;
|
|
36
|
+
encryption?: GrantEncryption;
|
|
37
|
+
applicationOrigin?: string;
|
|
38
|
+
keyHandle?: string;
|
|
39
|
+
savedAt: number;
|
|
40
|
+
authority?: {
|
|
41
|
+
operationsUrl: string;
|
|
42
|
+
syncUrl: string;
|
|
43
|
+
replicaId: string;
|
|
44
|
+
accessToken: string;
|
|
45
|
+
proofPublicKey?: string;
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export interface StoredConnectionIndex {
|
|
49
|
+
version: 1;
|
|
50
|
+
collectionIds: string[];
|
|
51
|
+
}
|
|
52
|
+
export interface PendingMutation {
|
|
53
|
+
collectionId: string;
|
|
54
|
+
grantId?: string;
|
|
55
|
+
keyId?: string;
|
|
56
|
+
operation: CollectionOperation;
|
|
57
|
+
inputFingerprint: string;
|
|
58
|
+
requestId: string;
|
|
59
|
+
envelope?: EncryptedRelayOperationRequest;
|
|
60
|
+
createdAt: number;
|
|
61
|
+
}
|
|
62
|
+
export interface OperationAttempt {
|
|
63
|
+
response: Response;
|
|
64
|
+
requestId: string;
|
|
65
|
+
encryptedRequest?: Awaited<ReturnType<typeof encryptRelayRequest>>;
|
|
66
|
+
directDeliveryUncertain?: boolean;
|
|
67
|
+
pendingMutation?: boolean;
|
|
68
|
+
resumingMutation?: boolean;
|
|
69
|
+
}
|
|
70
|
+
export declare const DEFAULT_OPERATIONS: CollectionOperation[];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const DEFAULT_OPERATIONS = ["describe", "changes", "read", "query"];
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export interface MdbaseNotificationRegistrationOptions {
|
|
2
|
+
serviceWorker: ServiceWorkerRegistration;
|
|
3
|
+
/** Manifest criterion IDs to enable. Omit to enable every declared criterion. */
|
|
4
|
+
criteria?: string[];
|
|
5
|
+
/** Stable per-installation ID. The SDK persists one when omitted. */
|
|
6
|
+
installationId?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface MdbaseNotificationRegistration {
|
|
9
|
+
channelId: string;
|
|
10
|
+
installationId: string;
|
|
11
|
+
criteria: string[];
|
|
12
|
+
}
|
|
13
|
+
export interface MdbaseNativeNotificationRegistrationOptions {
|
|
14
|
+
/** Current FCM registration token. Refresh by calling this method again. */
|
|
15
|
+
token: string;
|
|
16
|
+
/** Manifest criterion IDs to enable. Omit to enable every declared criterion. */
|
|
17
|
+
criteria?: string[];
|
|
18
|
+
/** Stable per-installation ID. The SDK persists one when omitted. */
|
|
19
|
+
installationId?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface MdbaseNativeNotificationRegistration extends MdbaseNotificationRegistration {
|
|
22
|
+
transport: "fcm";
|
|
23
|
+
}
|
|
24
|
+
export interface MdbaseNativeNotificationData {
|
|
25
|
+
type: "mdbase.notification";
|
|
26
|
+
version: 1;
|
|
27
|
+
signal_id: string;
|
|
28
|
+
criterion_id: string;
|
|
29
|
+
cursor: string;
|
|
30
|
+
}
|
|
31
|
+
export interface MdbasePushPayload {
|
|
32
|
+
type: "mdbase.notification";
|
|
33
|
+
version: 1;
|
|
34
|
+
signal_id: string;
|
|
35
|
+
criterion_id: string;
|
|
36
|
+
cursor: string;
|
|
37
|
+
presentation: {
|
|
38
|
+
title: string;
|
|
39
|
+
body?: string;
|
|
40
|
+
tag?: string;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export declare function parseMdbasePushPayload(value: unknown): MdbasePushPayload;
|
|
44
|
+
/** Parse the string-valued data attached to an APNs/FCM notification. */
|
|
45
|
+
export declare function parseMdbaseNativeNotificationData(value: unknown): MdbaseNativeNotificationData;
|
|
46
|
+
/** Display a validated mdbase push from a service worker `push` handler. */
|
|
47
|
+
export declare function showMdbasePushNotification(registration: Pick<ServiceWorkerRegistration, "showNotification">, value: unknown): Promise<void>;
|