@mdbase-dev/connect 0.1.0-beta.100
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 +696 -0
- package/dist/advanced.d.ts +9 -0
- package/dist/advanced.js +6 -0
- package/dist/application-contract.d.ts +22 -0
- package/dist/application-contract.js +75 -0
- package/dist/application-identity.d.ts +34 -0
- package/dist/application-identity.js +148 -0
- package/dist/application-session.d.ts +167 -0
- package/dist/application-session.js +722 -0
- package/dist/async.d.ts +1 -0
- package/dist/async.js +19 -0
- package/dist/authority-proof.d.ts +3 -0
- package/dist/authority-proof.js +24 -0
- package/dist/authority-sync-request.d.ts +3 -0
- package/dist/authority-sync-request.js +20 -0
- package/dist/authorization-refresh.d.ts +14 -0
- package/dist/authorization-refresh.js +41 -0
- package/dist/authorization-types.d.ts +7 -0
- package/dist/authorization-types.js +1 -0
- package/dist/authorization-url.d.ts +5 -0
- package/dist/authorization-url.js +34 -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/browser.d.ts +3 -0
- package/dist/browser.js +5 -0
- package/dist/capabilities.d.ts +24 -0
- package/dist/capabilities.js +91 -0
- package/dist/collection-client.d.ts +66 -0
- package/dist/collection-client.js +686 -0
- package/dist/connect-authorization-helpers.d.ts +4 -0
- package/dist/connect-authorization-helpers.js +25 -0
- package/dist/connect-options.d.ts +35 -0
- package/dist/connect-options.js +1 -0
- package/dist/connect.d.ts +66 -0
- package/dist/connect.js +711 -0
- package/dist/connection-file-transport.d.ts +34 -0
- package/dist/connection-file-transport.js +85 -0
- package/dist/connection-index.d.ts +3 -0
- package/dist/connection-index.js +20 -0
- package/dist/connection-notifications.d.ts +31 -0
- package/dist/connection-notifications.js +197 -0
- package/dist/connection-transport-internals.d.ts +24 -0
- package/dist/connection-transport-internals.js +1 -0
- package/dist/connection-transport.d.ts +64 -0
- package/dist/connection-transport.js +725 -0
- package/dist/connection-types.d.ts +38 -0
- package/dist/connection-types.js +1 -0
- package/dist/connection.d.ts +175 -0
- package/dist/connection.js +606 -0
- package/dist/crypto-entry.d.ts +7 -0
- package/dist/crypto-entry.js +4 -0
- package/dist/crypto.d.ts +63 -0
- package/dist/crypto.js +409 -0
- package/dist/direct-access.d.ts +3 -0
- package/dist/direct-access.js +29 -0
- package/dist/errors.d.ts +45 -0
- package/dist/errors.js +142 -0
- package/dist/external-store.d.ts +10 -0
- package/dist/external-store.js +10 -0
- package/dist/file-crypto.d.ts +25 -0
- package/dist/file-crypto.js +132 -0
- package/dist/file-sha256.d.ts +12 -0
- package/dist/file-sha256.js +143 -0
- package/dist/file-stream-source.d.ts +17 -0
- package/dist/file-stream-source.js +157 -0
- package/dist/file-transfer-internals.d.ts +23 -0
- package/dist/file-transfer-internals.js +228 -0
- package/dist/files.d.ts +115 -0
- package/dist/files.js +563 -0
- package/dist/fresh-encrypted-request.d.ts +4 -0
- package/dist/fresh-encrypted-request.js +28 -0
- package/dist/grant-key-leases.d.ts +24 -0
- package/dist/grant-key-leases.js +144 -0
- package/dist/hosted-file-request.d.ts +6 -0
- package/dist/hosted-file-request.js +63 -0
- package/dist/hosted-operation-response.d.ts +5 -0
- package/dist/hosted-operation-response.js +26 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +10 -0
- package/dist/internal-types.d.ts +100 -0
- package/dist/internal-types.js +1 -0
- package/dist/local-file-transport.d.ts +37 -0
- package/dist/local-file-transport.js +457 -0
- package/dist/mdbase-connect.d.ts +28 -0
- package/dist/mdbase-connect.js +53 -0
- package/dist/notifications.d.ts +48 -0
- package/dist/notifications.js +52 -0
- package/dist/operation-helpers.d.ts +27 -0
- package/dist/operation-helpers.js +144 -0
- package/dist/operation-types.d.ts +707 -0
- package/dist/operation-types.js +1 -0
- package/dist/outcomes.d.ts +51 -0
- package/dist/outcomes.js +194 -0
- package/dist/pending-mutation-store.d.ts +13 -0
- package/dist/pending-mutation-store.js +64 -0
- package/dist/pending-recovery-transports.d.ts +5 -0
- package/dist/pending-recovery-transports.js +52 -0
- package/dist/popup-authorization.d.ts +31 -0
- package/dist/popup-authorization.js +100 -0
- package/dist/query-pagination.d.ts +6 -0
- package/dist/query-pagination.js +119 -0
- package/dist/request-budget.d.ts +34 -0
- package/dist/request-budget.js +104 -0
- package/dist/request-coordinator.d.ts +29 -0
- package/dist/request-coordinator.js +213 -0
- package/dist/retired-grant-keys.d.ts +9 -0
- package/dist/retired-grant-keys.js +56 -0
- package/dist/runtime-utils.d.ts +52 -0
- package/dist/runtime-utils.js +368 -0
- package/dist/saved-view-pagination.d.ts +6 -0
- package/dist/saved-view-pagination.js +76 -0
- package/dist/selection.d.ts +48 -0
- package/dist/selection.js +112 -0
- package/dist/session.d.ts +114 -0
- package/dist/session.js +569 -0
- package/dist/shared-operation.d.ts +7 -0
- package/dist/shared-operation.js +41 -0
- package/dist/stored-token.d.ts +10 -0
- package/dist/stored-token.js +64 -0
- package/dist/structured-readiness.d.ts +77 -0
- package/dist/structured-readiness.js +126 -0
- package/dist/token-response.d.ts +12 -0
- package/dist/token-response.js +81 -0
- package/dist/transient-retry.d.ts +22 -0
- package/dist/transient-retry.js +59 -0
- package/dist/watch-policy.d.ts +8 -0
- package/dist/watch-policy.js +12 -0
- package/package.json +51 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { FileTransferSession } from "@mdbase-dev/connect-protocol";
|
|
2
|
+
import type { GrantKeyStore } from "./crypto.js";
|
|
3
|
+
import type { StoredToken } from "./internal-types.js";
|
|
4
|
+
type ProofHeaders = (token: StoredToken, method: "GET" | "POST" | "DELETE", url: string, body: string | undefined, credential: string) => Promise<Record<string, string>>;
|
|
5
|
+
export interface ConnectionFileTransportOptions {
|
|
6
|
+
keyStore: GrantKeyStore;
|
|
7
|
+
serverUrl: string;
|
|
8
|
+
loopbackUrl: string;
|
|
9
|
+
collectionId: string;
|
|
10
|
+
currentToken(): StoredToken | null;
|
|
11
|
+
authorizedToken(signal?: AbortSignal): Promise<StoredToken | null>;
|
|
12
|
+
refreshAuthorization(signal?: AbortSignal): Promise<StoredToken>;
|
|
13
|
+
shouldAttemptDirect(token: StoredToken): Promise<boolean>;
|
|
14
|
+
onDirectAvailable(): void;
|
|
15
|
+
onDirectUnavailable(): void;
|
|
16
|
+
onRelayAvailable(): void;
|
|
17
|
+
authorityProofHeaders: ProofHeaders;
|
|
18
|
+
acquireGrantKeyLease(collectionId: string, keyHandle: string, signal?: AbortSignal): Promise<() => void>;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Owns every transport concern specific to files. ConnectionTransport only
|
|
22
|
+
* supplies authorization, routing, and proof-signing policy.
|
|
23
|
+
*/
|
|
24
|
+
export declare class ConnectionFileTransport {
|
|
25
|
+
private readonly options;
|
|
26
|
+
private readonly local;
|
|
27
|
+
constructor(options: ConnectionFileTransportOptions);
|
|
28
|
+
control<Result>(method: "GET" | "POST" | "DELETE", path?: string, input?: unknown, signal?: AbortSignal): Promise<Result>;
|
|
29
|
+
uploadChunk(session: FileTransferSession, chunkIndex: number, bytes: Uint8Array, signal?: AbortSignal): Promise<void>;
|
|
30
|
+
downloadChunk(session: FileTransferSession, chunkIndex: number, signal?: AbortSignal): Promise<Uint8Array>;
|
|
31
|
+
downloadHostedPart(session: FileTransferSession, partIndex: number, expectedLength: number, signal?: AbortSignal): Promise<ReadableStream<Uint8Array>>;
|
|
32
|
+
private requireFileToken;
|
|
33
|
+
}
|
|
34
|
+
export {};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { connectError } from "./errors.js";
|
|
2
|
+
import { performHostedFilePartRequest, performHostedFileRequest } from "./hosted-file-request.js";
|
|
3
|
+
import { GrantKeyLeaseSet, retainCurrentGrantToken } from "./grant-key-leases.js";
|
|
4
|
+
import { LocalFileTransport } from "./local-file-transport.js";
|
|
5
|
+
/**
|
|
6
|
+
* Owns every transport concern specific to files. ConnectionTransport only
|
|
7
|
+
* supplies authorization, routing, and proof-signing policy.
|
|
8
|
+
*/
|
|
9
|
+
export class ConnectionFileTransport {
|
|
10
|
+
options;
|
|
11
|
+
local;
|
|
12
|
+
constructor(options) {
|
|
13
|
+
this.options = options;
|
|
14
|
+
this.local = new LocalFileTransport(options);
|
|
15
|
+
}
|
|
16
|
+
async control(method, path = "", input, signal) {
|
|
17
|
+
const leased = await this.requireFileToken(signal);
|
|
18
|
+
const token = leased.token;
|
|
19
|
+
try {
|
|
20
|
+
if (!token.authority) {
|
|
21
|
+
return await this.local.control(token, method, path, input, signal);
|
|
22
|
+
}
|
|
23
|
+
return await performHostedFileRequest(token, method, path, input, signal, this.options.refreshAuthorization, this.options.authorityProofHeaders, leased.retain);
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
leased.release();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async uploadChunk(session, chunkIndex, bytes, signal) {
|
|
30
|
+
const leased = await this.requireFileToken(signal);
|
|
31
|
+
try {
|
|
32
|
+
return await this.local.uploadChunk(session, chunkIndex, bytes, signal, leased.token);
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
leased.release();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async downloadChunk(session, chunkIndex, signal) {
|
|
39
|
+
const leased = await this.requireFileToken(signal);
|
|
40
|
+
try {
|
|
41
|
+
return await this.local.downloadChunk(session, chunkIndex, signal, leased.token);
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
leased.release();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async downloadHostedPart(session, partIndex, expectedLength, signal) {
|
|
48
|
+
const leased = await this.requireFileToken(signal);
|
|
49
|
+
const token = leased.token;
|
|
50
|
+
try {
|
|
51
|
+
if (!token.authority) {
|
|
52
|
+
throw connectError("not_remote_authority", "Hosted file delivery requires a remote authority endpoint.");
|
|
53
|
+
}
|
|
54
|
+
return await performHostedFilePartRequest(token, `downloads/${encodeURIComponent(session.transfer_id)}/parts/${partIndex}`, expectedLength, signal, this.options.refreshAuthorization, this.options.authorityProofHeaders, leased.retain);
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
leased.release();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async requireFileToken(signal) {
|
|
61
|
+
const leases = new GrantKeyLeaseSet(this.options.collectionId, this.options.acquireGrantKeyLease);
|
|
62
|
+
try {
|
|
63
|
+
await retainCurrentGrantToken(this.options.currentToken, leases, signal);
|
|
64
|
+
await this.options.authorizedToken(signal);
|
|
65
|
+
const token = await retainCurrentGrantToken(this.options.currentToken, leases, signal);
|
|
66
|
+
if (!token?.fileCapability) {
|
|
67
|
+
throw connectError("not_authorized", "This connection has no file capability.");
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
token,
|
|
71
|
+
retain: async (_refreshed) => {
|
|
72
|
+
const current = await retainCurrentGrantToken(this.options.currentToken, leases, signal);
|
|
73
|
+
if (current)
|
|
74
|
+
return current;
|
|
75
|
+
throw connectError("not_authorized", "Reconnect this application to continue.");
|
|
76
|
+
},
|
|
77
|
+
release: () => leases.release()
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
leases.release();
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export declare function connectionIds(storage: Storage, storagePrefix: string): string[];
|
|
2
|
+
export declare function addConnectionId(storage: Storage, storagePrefix: string, collectionId: string): void;
|
|
3
|
+
export declare function removeConnectionId(storage: Storage, storagePrefix: string, collectionId: string): void;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { parseStored } from "./runtime-utils.js";
|
|
2
|
+
export function connectionIds(storage, storagePrefix) {
|
|
3
|
+
const index = parseStored(storage.getItem(`${storagePrefix}:connections`));
|
|
4
|
+
return index?.version === 1 && Array.isArray(index.collectionIds)
|
|
5
|
+
&& index.collectionIds.every((collectionId) => typeof collectionId === "string")
|
|
6
|
+
? index.collectionIds
|
|
7
|
+
: [];
|
|
8
|
+
}
|
|
9
|
+
export function addConnectionId(storage, storagePrefix, collectionId) {
|
|
10
|
+
writeConnectionIds(storage, storagePrefix, [...connectionIds(storage, storagePrefix), collectionId]);
|
|
11
|
+
}
|
|
12
|
+
export function removeConnectionId(storage, storagePrefix, collectionId) {
|
|
13
|
+
writeConnectionIds(storage, storagePrefix, connectionIds(storage, storagePrefix).filter((id) => id !== collectionId));
|
|
14
|
+
}
|
|
15
|
+
function writeConnectionIds(storage, storagePrefix, ids) {
|
|
16
|
+
storage.setItem(`${storagePrefix}:connections`, JSON.stringify({
|
|
17
|
+
version: 1,
|
|
18
|
+
collectionIds: [...new Set(ids)]
|
|
19
|
+
}));
|
|
20
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Application } from "./internal-types.js";
|
|
2
|
+
import type { StoredToken } from "./internal-types.js";
|
|
3
|
+
import type { ConnectRequestOptions } from "./operation-types.js";
|
|
4
|
+
import type { MdbaseNativeNotificationRegistration, MdbaseNativeNotificationRegistrationOptions, MdbaseNotificationRegistration, MdbaseNotificationRegistrationOptions } from "./notifications.js";
|
|
5
|
+
export interface ConnectionNotificationContext {
|
|
6
|
+
serverUrl: string;
|
|
7
|
+
storage: Storage;
|
|
8
|
+
authorizedToken(signal?: AbortSignal): Promise<StoredToken | null>;
|
|
9
|
+
register(signal?: AbortSignal): Promise<Application>;
|
|
10
|
+
notificationKey(transport?: "web_push" | "fcm"): string;
|
|
11
|
+
requestTimeoutMs: number | null;
|
|
12
|
+
}
|
|
13
|
+
export declare class ConnectionNotifications {
|
|
14
|
+
private readonly context;
|
|
15
|
+
constructor(context: ConnectionNotificationContext);
|
|
16
|
+
registerNotifications(options: MdbaseNotificationRegistrationOptions): Promise<MdbaseNotificationRegistration>;
|
|
17
|
+
private registerNotificationsWithinBudget;
|
|
18
|
+
/**
|
|
19
|
+
* Register an iOS or Android installation for Connect-managed FCM.
|
|
20
|
+
*
|
|
21
|
+
* The application manifest selects the Firebase project. Re-register the
|
|
22
|
+
* same installation whenever Firebase refreshes its token.
|
|
23
|
+
*/
|
|
24
|
+
registerNativeNotifications(options: MdbaseNativeNotificationRegistrationOptions): Promise<MdbaseNativeNotificationRegistration>;
|
|
25
|
+
private registerNativeNotificationsWithinBudget;
|
|
26
|
+
unregisterNativeNotifications(options?: ConnectRequestOptions): Promise<void>;
|
|
27
|
+
private unregisterNativeNotificationsWithinBudget;
|
|
28
|
+
unregisterNotifications(serviceWorker?: ServiceWorkerRegistration, options?: ConnectRequestOptions): Promise<void>;
|
|
29
|
+
private unregisterNotificationsWithinBudget;
|
|
30
|
+
private deleteNotificationChannel;
|
|
31
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { connectError } from "./errors.js";
|
|
2
|
+
import { apiError, connectFetch, decodeJsonResponse, parseStored } from "./runtime-utils.js";
|
|
3
|
+
import { base64UrlBytes, randomBase64Url } from "./base64.js";
|
|
4
|
+
import { withRequestBudget } from "./request-budget.js";
|
|
5
|
+
export class ConnectionNotifications {
|
|
6
|
+
context;
|
|
7
|
+
constructor(context) {
|
|
8
|
+
this.context = context;
|
|
9
|
+
}
|
|
10
|
+
async registerNotifications(options) {
|
|
11
|
+
return withRequestBudget(options, this.context.requestTimeoutMs, (budget) => this.registerNotificationsWithinBudget(options, budget.signal));
|
|
12
|
+
}
|
|
13
|
+
async registerNotificationsWithinBudget(options, signal) {
|
|
14
|
+
const token = await this.context.authorizedToken(signal);
|
|
15
|
+
if (!token) {
|
|
16
|
+
throw connectError("not_authorized", "Connect this application before enabling notifications.");
|
|
17
|
+
}
|
|
18
|
+
const application = await this.context.register(signal);
|
|
19
|
+
const declared = application.notifications?.criteria.map((criterion) => criterion.id) ?? [];
|
|
20
|
+
const criteria = [...new Set(options.criteria ?? declared)];
|
|
21
|
+
const undeclared = criteria.find((criterion) => !declared.includes(criterion));
|
|
22
|
+
if (undeclared) {
|
|
23
|
+
throw connectError("notification_criterion_not_declared", `The application manifest does not declare notification criterion ${undeclared}.`);
|
|
24
|
+
}
|
|
25
|
+
if (criteria.length === 0) {
|
|
26
|
+
throw connectError("notifications_not_declared", "This application manifest does not declare any notification criteria.");
|
|
27
|
+
}
|
|
28
|
+
const keyResponse = await connectFetch(`${this.context.serverUrl}/v1/notifications/vapid-public-key`, { signal }, "notifications_unavailable", "Push notifications are unavailable.");
|
|
29
|
+
const keyBody = await decodeJsonResponse(keyResponse, "invalid_operation_response", "Connect returned an invalid notification key response.");
|
|
30
|
+
if (!keyResponse.ok) {
|
|
31
|
+
throw apiError(keyBody, "notifications_unavailable", "Push notifications are unavailable.", keyResponse.status);
|
|
32
|
+
}
|
|
33
|
+
let pushSubscription = await options.serviceWorker.pushManager.getSubscription();
|
|
34
|
+
if (!pushSubscription) {
|
|
35
|
+
pushSubscription = await options.serviceWorker.pushManager.subscribe({
|
|
36
|
+
userVisibleOnly: true,
|
|
37
|
+
applicationServerKey: base64UrlBytes(keyBody.public_key)
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
const serialized = pushSubscription.toJSON();
|
|
41
|
+
if (!serialized.endpoint || !serialized.keys?.p256dh || !serialized.keys.auth) {
|
|
42
|
+
throw connectError("invalid_push_subscription", "The browser returned an incomplete push subscription.");
|
|
43
|
+
}
|
|
44
|
+
const previous = parseStored(this.context.storage.getItem(this.context.notificationKey()));
|
|
45
|
+
const installationId = options.installationId
|
|
46
|
+
?? previous?.installationId
|
|
47
|
+
?? randomBase64Url(24);
|
|
48
|
+
const channelResponse = await connectFetch(`${this.context.serverUrl}/v1/notifications/channels`, {
|
|
49
|
+
method: "POST",
|
|
50
|
+
headers: {
|
|
51
|
+
authorization: `Bearer ${token.accessToken}`,
|
|
52
|
+
"content-type": "application/json"
|
|
53
|
+
},
|
|
54
|
+
body: JSON.stringify({
|
|
55
|
+
installation_id: installationId,
|
|
56
|
+
criteria,
|
|
57
|
+
subscription: {
|
|
58
|
+
endpoint: serialized.endpoint,
|
|
59
|
+
expirationTime: serialized.expirationTime ?? null,
|
|
60
|
+
keys: serialized.keys
|
|
61
|
+
}
|
|
62
|
+
}),
|
|
63
|
+
signal
|
|
64
|
+
}, "notification_registration_failed", "Could not register push notifications.");
|
|
65
|
+
const channelBody = await decodeJsonResponse(channelResponse, "invalid_operation_response", "Connect returned an invalid notification registration response.");
|
|
66
|
+
if (!channelResponse.ok) {
|
|
67
|
+
throw apiError(channelBody, "notification_registration_failed", "Could not register push notifications.", channelResponse.status);
|
|
68
|
+
}
|
|
69
|
+
if (previous?.channelId && previous.channelId !== channelBody.channel_id) {
|
|
70
|
+
void fetch(`${this.context.serverUrl}/v1/notifications/channels/${previous.channelId}`, {
|
|
71
|
+
method: "DELETE",
|
|
72
|
+
headers: { authorization: `Bearer ${token.accessToken}` },
|
|
73
|
+
signal
|
|
74
|
+
}).catch(() => undefined);
|
|
75
|
+
}
|
|
76
|
+
const registration = {
|
|
77
|
+
channelId: channelBody.channel_id,
|
|
78
|
+
installationId,
|
|
79
|
+
criteria
|
|
80
|
+
};
|
|
81
|
+
this.context.storage.setItem(this.context.notificationKey(), JSON.stringify(registration));
|
|
82
|
+
return registration;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Register an iOS or Android installation for Connect-managed FCM.
|
|
86
|
+
*
|
|
87
|
+
* The application manifest selects the Firebase project. Re-register the
|
|
88
|
+
* same installation whenever Firebase refreshes its token.
|
|
89
|
+
*/
|
|
90
|
+
async registerNativeNotifications(options) {
|
|
91
|
+
return withRequestBudget(options, this.context.requestTimeoutMs, (budget) => this.registerNativeNotificationsWithinBudget(options, budget.signal));
|
|
92
|
+
}
|
|
93
|
+
async registerNativeNotificationsWithinBudget(options, signal) {
|
|
94
|
+
const token = await this.context.authorizedToken(signal);
|
|
95
|
+
if (!token) {
|
|
96
|
+
throw connectError("not_authorized", "Connect this application before enabling notifications.");
|
|
97
|
+
}
|
|
98
|
+
const application = await this.context.register(signal);
|
|
99
|
+
if (application.notifications?.native_delivery?.mode !== "managed_fcm") {
|
|
100
|
+
throw connectError("managed_fcm_not_declared", "This application does not declare Connect-managed native notifications.");
|
|
101
|
+
}
|
|
102
|
+
const declared = application.notifications.criteria.map((criterion) => criterion.id);
|
|
103
|
+
const criteria = [...new Set(options.criteria ?? declared)];
|
|
104
|
+
const undeclared = criteria.find((criterion) => !declared.includes(criterion));
|
|
105
|
+
if (undeclared) {
|
|
106
|
+
throw connectError("notification_criterion_not_declared", `The application manifest does not declare notification criterion ${undeclared}.`);
|
|
107
|
+
}
|
|
108
|
+
if (criteria.length === 0) {
|
|
109
|
+
throw connectError("notifications_not_declared", "This application manifest does not declare any notification criteria.");
|
|
110
|
+
}
|
|
111
|
+
const storageKey = this.context.notificationKey("fcm");
|
|
112
|
+
const previous = parseStored(this.context.storage.getItem(storageKey));
|
|
113
|
+
const installationId = options.installationId
|
|
114
|
+
?? previous?.installationId
|
|
115
|
+
?? randomBase64Url(24);
|
|
116
|
+
const response = await connectFetch(`${this.context.serverUrl}/v1/notifications/channels`, {
|
|
117
|
+
method: "POST",
|
|
118
|
+
headers: {
|
|
119
|
+
authorization: `Bearer ${token.accessToken}`,
|
|
120
|
+
"content-type": "application/json"
|
|
121
|
+
},
|
|
122
|
+
body: JSON.stringify({
|
|
123
|
+
installation_id: installationId,
|
|
124
|
+
criteria,
|
|
125
|
+
transport: "fcm",
|
|
126
|
+
token: options.token
|
|
127
|
+
}),
|
|
128
|
+
signal
|
|
129
|
+
}, "notification_registration_failed", "Could not register native notifications.");
|
|
130
|
+
const body = await decodeJsonResponse(response, "invalid_operation_response", "Connect returned an invalid native notification response.");
|
|
131
|
+
if (!response.ok) {
|
|
132
|
+
throw apiError(body, "notification_registration_failed", "Could not register native notifications.", response.status);
|
|
133
|
+
}
|
|
134
|
+
if (previous?.channelId && previous.channelId !== body.channel_id) {
|
|
135
|
+
void this.deleteNotificationChannel(previous.channelId, token.accessToken, signal)
|
|
136
|
+
.catch(() => undefined);
|
|
137
|
+
}
|
|
138
|
+
const registration = {
|
|
139
|
+
channelId: body.channel_id,
|
|
140
|
+
installationId,
|
|
141
|
+
transport: "fcm",
|
|
142
|
+
criteria
|
|
143
|
+
};
|
|
144
|
+
this.context.storage.setItem(storageKey, JSON.stringify(registration));
|
|
145
|
+
return registration;
|
|
146
|
+
}
|
|
147
|
+
async unregisterNativeNotifications(options = {}) {
|
|
148
|
+
return withRequestBudget(options, this.context.requestTimeoutMs, (budget) => this.unregisterNativeNotificationsWithinBudget(budget.signal));
|
|
149
|
+
}
|
|
150
|
+
async unregisterNativeNotificationsWithinBudget(signal) {
|
|
151
|
+
const storageKey = this.context.notificationKey("fcm");
|
|
152
|
+
const registration = parseStored(this.context.storage.getItem(storageKey));
|
|
153
|
+
const token = await this.context.authorizedToken(signal);
|
|
154
|
+
if (registration?.channelId && !token) {
|
|
155
|
+
throw connectError("not_authorized", "Reconnect this application before disabling native notifications.");
|
|
156
|
+
}
|
|
157
|
+
if (registration?.channelId && token) {
|
|
158
|
+
await this.deleteNotificationChannel(registration.channelId, token.accessToken, signal);
|
|
159
|
+
}
|
|
160
|
+
this.context.storage.removeItem(storageKey);
|
|
161
|
+
}
|
|
162
|
+
async unregisterNotifications(serviceWorker, options = {}) {
|
|
163
|
+
return withRequestBudget(options, this.context.requestTimeoutMs, (budget) => this.unregisterNotificationsWithinBudget(serviceWorker, budget.signal));
|
|
164
|
+
}
|
|
165
|
+
async unregisterNotificationsWithinBudget(serviceWorker, signal) {
|
|
166
|
+
const registration = parseStored(this.context.storage.getItem(this.context.notificationKey()));
|
|
167
|
+
const token = await this.context.authorizedToken(signal);
|
|
168
|
+
if (registration?.channelId && !token) {
|
|
169
|
+
throw connectError("not_authorized", "Reconnect this application before disabling push notifications.");
|
|
170
|
+
}
|
|
171
|
+
if (registration?.channelId && token) {
|
|
172
|
+
const response = await connectFetch(`${this.context.serverUrl}/v1/notifications/channels/${registration.channelId}`, {
|
|
173
|
+
method: "DELETE",
|
|
174
|
+
headers: { authorization: `Bearer ${token.accessToken}` },
|
|
175
|
+
signal
|
|
176
|
+
}, "notification_unregistration_failed", "Could not unregister push notifications.");
|
|
177
|
+
if (!response.ok && response.status !== 404) {
|
|
178
|
+
const body = await decodeJsonResponse(response, "invalid_operation_response", "Connect returned an invalid notification response.");
|
|
179
|
+
throw apiError(body, "notification_unregistration_failed", "Could not unregister push notifications.", response.status);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
this.context.storage.removeItem(this.context.notificationKey());
|
|
183
|
+
const subscription = await serviceWorker?.pushManager.getSubscription();
|
|
184
|
+
await subscription?.unsubscribe();
|
|
185
|
+
}
|
|
186
|
+
async deleteNotificationChannel(channelId, accessToken, signal) {
|
|
187
|
+
const response = await connectFetch(`${this.context.serverUrl}/v1/notifications/channels/${channelId}`, {
|
|
188
|
+
method: "DELETE",
|
|
189
|
+
headers: { authorization: `Bearer ${accessToken}` },
|
|
190
|
+
signal
|
|
191
|
+
}, "notification_unregistration_failed", "Could not unregister push notifications.");
|
|
192
|
+
if (!response.ok && response.status !== 404) {
|
|
193
|
+
const body = await decodeJsonResponse(response, "invalid_operation_response", "Connect returned an invalid notification response.");
|
|
194
|
+
throw apiError(body, "notification_unregistration_failed", "Could not unregister push notifications.", response.status);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { GrantKeyStore } from "./crypto.js";
|
|
2
|
+
import type { StoredToken } from "./internal-types.js";
|
|
3
|
+
import type { ResolvedConnectTimeouts } from "./request-budget.js";
|
|
4
|
+
export interface ConnectionTransportInternals {
|
|
5
|
+
readonly relayEncryption: "required" | "disabled";
|
|
6
|
+
removeToken(collectionId: string, keyHandle?: string, reason?: "not_authorized" | "authorization_lost" | "invalid_stored_grant" | "legacy_scope_reauthorization_required", discardPending?: boolean): void;
|
|
7
|
+
storeTokenResponse(body: any, clientId: string, keyHandle?: string): StoredToken;
|
|
8
|
+
acquireGrantKeyLease(collectionId: string, keyHandle: string, signal?: AbortSignal): Promise<() => void>;
|
|
9
|
+
deleteGrantKeyWhenUnused(collectionId: string, keyHandle: string): void;
|
|
10
|
+
tokenKey(collectionId: string): string;
|
|
11
|
+
pendingMutationKey(collectionId: string): string;
|
|
12
|
+
directPreferenceKey(): string;
|
|
13
|
+
}
|
|
14
|
+
export interface ConnectionTransportOptions {
|
|
15
|
+
serverUrl: string;
|
|
16
|
+
storage: Storage;
|
|
17
|
+
keyStore: GrantKeyStore;
|
|
18
|
+
directAccessMode: "auto" | "disabled";
|
|
19
|
+
loopbackUrl: string;
|
|
20
|
+
collectionId: string;
|
|
21
|
+
internals: ConnectionTransportInternals;
|
|
22
|
+
onChange(): void;
|
|
23
|
+
timeouts: ResolvedConnectTimeouts;
|
|
24
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { CollectionOperation } from "@mdbase-dev/connect-protocol";
|
|
2
|
+
import type { StoredToken } from "./internal-types.js";
|
|
3
|
+
import type { DirectAccessStatus, MdbaseConnectionRoute } from "./connection-types.js";
|
|
4
|
+
import type { ConnectRequestOptions, PendingMutationSummary } from "./operation-types.js";
|
|
5
|
+
import { ConnectionFileTransport } from "./connection-file-transport.js";
|
|
6
|
+
import type { ConnectionTransportOptions } from "./connection-transport-internals.js";
|
|
7
|
+
export type { ConnectionTransportInternals, ConnectionTransportOptions } from "./connection-transport-internals.js";
|
|
8
|
+
export declare class ConnectionTransport {
|
|
9
|
+
private readonly serverUrl;
|
|
10
|
+
private readonly storage;
|
|
11
|
+
private readonly keyStore;
|
|
12
|
+
private readonly directAccessMode;
|
|
13
|
+
private readonly loopbackUrl;
|
|
14
|
+
private readonly collectionId;
|
|
15
|
+
private readonly internals;
|
|
16
|
+
private readonly onChange;
|
|
17
|
+
private readonly timeouts;
|
|
18
|
+
private readonly pendingMutationStore;
|
|
19
|
+
readonly files: ConnectionFileTransport;
|
|
20
|
+
private readonly refreshOperations;
|
|
21
|
+
private directStatus;
|
|
22
|
+
private currentRoute;
|
|
23
|
+
private directFailures;
|
|
24
|
+
private directRetryAt;
|
|
25
|
+
constructor(options: ConnectionTransportOptions);
|
|
26
|
+
get directAccess(): DirectAccessStatus;
|
|
27
|
+
get route(): MdbaseConnectionRoute;
|
|
28
|
+
notifyStorageChanged(): void;
|
|
29
|
+
checkDirectAccess(options?: ConnectRequestOptions): Promise<DirectAccessStatus>;
|
|
30
|
+
private checkDirectAccessWithinBudget;
|
|
31
|
+
/** Call from a user gesture to request browser permission for direct local access. */
|
|
32
|
+
requestDirectAccess(options?: ConnectRequestOptions): Promise<DirectAccessStatus>;
|
|
33
|
+
private requestDirectAccessWithinBudget;
|
|
34
|
+
disableDirectAccess(): void;
|
|
35
|
+
/** Return pending writes without exposing authority transport credentials. */
|
|
36
|
+
pendingMutations(): readonly PendingMutationSummary[];
|
|
37
|
+
pendingMutation(requestId: string): PendingMutationSummary | null;
|
|
38
|
+
recoverPendingMutation<Result>(requestId: string, options?: ConnectRequestOptions): Promise<Result>;
|
|
39
|
+
performOperation<Result>(operation: CollectionOperation, input: unknown, options?: ConnectRequestOptions): Promise<Result>;
|
|
40
|
+
private performOperationWithinBudget;
|
|
41
|
+
private performLeasedOperation;
|
|
42
|
+
performAuthoritySyncRequest<Result>(collectionId: string, replicaId: string, method: "GET" | "POST", path: string, input?: unknown, options?: ConnectRequestOptions): Promise<Result>;
|
|
43
|
+
private sendOperation;
|
|
44
|
+
private directCapable;
|
|
45
|
+
hasResumableMutationTransport(): boolean;
|
|
46
|
+
private directEligible;
|
|
47
|
+
private shouldAttemptDirect;
|
|
48
|
+
private probeDirectAccess;
|
|
49
|
+
private markDirectAvailable;
|
|
50
|
+
private markDirectUnavailable;
|
|
51
|
+
private setDirectStatus;
|
|
52
|
+
private setRoute;
|
|
53
|
+
private emitConnection;
|
|
54
|
+
private invalidateRejectedAuthorization;
|
|
55
|
+
currentToken(): StoredToken | null;
|
|
56
|
+
authorizedToken(options?: ConnectRequestOptions): Promise<StoredToken | null>;
|
|
57
|
+
private refreshAuthorization;
|
|
58
|
+
private performRefresh;
|
|
59
|
+
private storeTokenResponse;
|
|
60
|
+
private storedPendingMutations;
|
|
61
|
+
private storedPendingMutation;
|
|
62
|
+
private clearPendingMutation;
|
|
63
|
+
private grantKeyLeases;
|
|
64
|
+
}
|