@aglyn/plugins-marketplace 1.14.0 → 1.15.1
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/README.md +1 -0
- package/package.json +15 -14
- package/src/lib/components/editor-connections-provider.component.d.ts +37 -0
- package/src/lib/components/editor-connections-provider.component.js +161 -0
- package/src/lib/components/editor-connections-provider.component.js.map +1 -0
- package/src/lib/components/listing-connection-credentials-card.component.d.ts +33 -0
- package/src/lib/components/listing-connection-credentials-card.component.js +318 -0
- package/src/lib/components/listing-connection-credentials-card.component.js.map +1 -0
- package/src/lib/components/listing-detail-page.component.js +10 -2
- package/src/lib/components/listing-detail-page.component.js.map +1 -1
- package/src/lib/components/site-plugin-connections-card.component.d.ts +38 -0
- package/src/lib/components/site-plugin-connections-card.component.js +576 -0
- package/src/lib/components/site-plugin-connections-card.component.js.map +1 -0
- package/src/lib/declarations.console-server.d.ts +17 -0
- package/src/lib/declarations.console-server.js +41 -0
- package/src/lib/declarations.console-server.js.map +1 -0
- package/src/lib/declarations.js +11 -2
- package/src/lib/declarations.js.map +1 -1
- package/src/lib/declarations.server.js +68 -1
- package/src/lib/declarations.server.js.map +1 -1
- package/src/lib/hooks/use-plugin-connections-api.d.ts +89 -0
- package/src/lib/hooks/use-plugin-connections-api.js +132 -0
- package/src/lib/hooks/use-plugin-connections-api.js.map +1 -0
- package/src/lib/model/editor-connections.d.ts +46 -0
- package/src/lib/model/editor-connections.js +70 -0
- package/src/lib/model/editor-connections.js.map +1 -0
- package/src/lib/model/listing-price-floor.js +7 -4
- package/src/lib/model/listing-price-floor.js.map +1 -1
- package/src/lib/model/marketplace-publisher-permissions.d.ts +3 -1
- package/src/lib/model/marketplace-publisher-permissions.js +14 -0
- package/src/lib/model/marketplace-publisher-permissions.js.map +1 -1
- package/src/lib/model/marketplace-site-permissions.d.ts +9 -0
- package/src/lib/model/marketplace-site-permissions.js +29 -0
- package/src/lib/model/marketplace-site-permissions.js.map +1 -1
- package/src/lib/model/plugin-connection-card.d.ts +88 -0
- package/src/lib/model/plugin-connection-card.js +187 -0
- package/src/lib/model/plugin-connection-card.js.map +1 -0
- package/src/lib/model/plugin-connections.d.ts +172 -0
- package/src/lib/model/plugin-connections.js +119 -0
- package/src/lib/model/plugin-connections.js.map +1 -0
- package/src/lib/plugin.js +21 -1
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/server/admin-reviews.js +18 -12
- package/src/lib/server/admin-reviews.js.map +1 -1
- package/src/lib/server/artifact-inventory.js +3 -1
- package/src/lib/server/artifact-inventory.js.map +1 -1
- package/src/lib/server/checkout.js +4 -0
- package/src/lib/server/checkout.js.map +1 -1
- package/src/lib/server/connections/cleanup.d.ts +84 -0
- package/src/lib/server/connections/cleanup.js +116 -0
- package/src/lib/server/connections/cleanup.js.map +1 -0
- package/src/lib/server/connections/config.d.ts +42 -0
- package/src/lib/server/connections/config.js +48 -0
- package/src/lib/server/connections/config.js.map +1 -0
- package/src/lib/server/connections/connection-store.d.ts +173 -0
- package/src/lib/server/connections/connection-store.js +331 -0
- package/src/lib/server/connections/connection-store.js.map +1 -0
- package/src/lib/server/connections/credentials-store.d.ts +71 -0
- package/src/lib/server/connections/credentials-store.js +78 -0
- package/src/lib/server/connections/credentials-store.js.map +1 -0
- package/src/lib/server/connections/erasure.d.ts +25 -0
- package/src/lib/server/connections/erasure.js +72 -0
- package/src/lib/server/connections/erasure.js.map +1 -0
- package/src/lib/server/connections/installed-connection.d.ts +64 -0
- package/src/lib/server/connections/installed-connection.js +92 -0
- package/src/lib/server/connections/installed-connection.js.map +1 -0
- package/src/lib/server/connections/oauth-state.d.ts +129 -0
- package/src/lib/server/connections/oauth-state.js +252 -0
- package/src/lib/server/connections/oauth-state.js.map +1 -0
- package/src/lib/server/connections/provider-http.d.ts +99 -0
- package/src/lib/server/connections/provider-http.js +253 -0
- package/src/lib/server/connections/provider-http.js.map +1 -0
- package/src/lib/server/connections/record-announce.d.ts +20 -0
- package/src/lib/server/connections/record-announce.js +53 -0
- package/src/lib/server/connections/record-announce.js.map +1 -0
- package/src/lib/server/connections/record-mapping.d.ts +63 -0
- package/src/lib/server/connections/record-mapping.js +151 -0
- package/src/lib/server/connections/record-mapping.js.map +1 -0
- package/src/lib/server/connections/record-routes.d.ts +17 -0
- package/src/lib/server/connections/record-routes.js +96 -0
- package/src/lib/server/connections/record-routes.js.map +1 -0
- package/src/lib/server/connections/revoke.d.ts +29 -0
- package/src/lib/server/connections/revoke.js +52 -0
- package/src/lib/server/connections/revoke.js.map +1 -0
- package/src/lib/server/connections/routes.d.ts +77 -0
- package/src/lib/server/connections/routes.js +706 -0
- package/src/lib/server/connections/routes.js.map +1 -0
- package/src/lib/server/connections/site-records-platform.d.ts +24 -0
- package/src/lib/server/connections/site-records-platform.js +94 -0
- package/src/lib/server/connections/site-records-platform.js.map +1 -0
- package/src/lib/server/connections/site-records.d.ts +95 -0
- package/src/lib/server/connections/site-records.js +221 -0
- package/src/lib/server/connections/site-records.js.map +1 -0
- package/src/lib/server/connections/sync.d.ts +110 -0
- package/src/lib/server/connections/sync.js +414 -0
- package/src/lib/server/connections/sync.js.map +1 -0
- package/src/lib/server/install-plugin.js +56 -6
- package/src/lib/server/install-plugin.js.map +1 -1
- package/src/lib/server/publish-plugin.js +5 -2
- package/src/lib/server/publish-plugin.js.map +1 -1
- package/src/lib/server/revenue-source.js +6 -5
- package/src/lib/server/revenue-source.js.map +1 -1
- package/src/lib/server.js +60 -0
- package/src/lib/server.js.map +1 -1
- package/src/lib/testing/memory-firestore.d.ts +34 -0
- package/src/lib/testing/memory-firestore.js +250 -0
- package/src/lib/testing/memory-firestore.js.map +1 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* A SITE'S DECLARED CONNECTION, AND ITS SEALED GRANT (AGL-4193).
|
|
19
|
+
*
|
|
20
|
+
* `hosts/{hostId}/pluginConnections/{listingId}__{connectionId}` — one per
|
|
21
|
+
* site, listing and declared connection. Copied in shape from accounting's
|
|
22
|
+
* ledger connection (`libs/plugins/accounting/.../connection-store.ts`), not
|
|
23
|
+
* imported: one plugin never imports another.
|
|
24
|
+
*
|
|
25
|
+
* The refresh and access tokens are sealed with `PLUGIN_CONNECTIONS_TOKEN_KEY`
|
|
26
|
+
* (secret box, AES-256-GCM), each bound to this document and its purpose as
|
|
27
|
+
* the seal's context, so a value copied onto another site's document refuses
|
|
28
|
+
* to open there. The rules refuse every client the whole collection; the
|
|
29
|
+
* console reads {@link toConnectionView}, which carries no token, through the
|
|
30
|
+
* status route.
|
|
31
|
+
*
|
|
32
|
+
* ## Rotation, and only one refresher
|
|
33
|
+
*
|
|
34
|
+
* Many providers hand back a new refresh token on a refresh and retire the
|
|
35
|
+
* old one soon after. Two processes refreshing the same grant at once — the
|
|
36
|
+
* picker and the scheduled sync — would each spend the old token, and one
|
|
37
|
+
* would store a token the provider has already replaced. So a refresh first
|
|
38
|
+
* takes a short LEASE on the document in a transaction; a second refresher
|
|
39
|
+
* that finds the lease held waits for the first's result instead. A provider
|
|
40
|
+
* that refuses the grant (`invalid_grant`, a 401) moves the connection to
|
|
41
|
+
* `reconnect_required`, which only the site owner connecting again clears.
|
|
42
|
+
*/
|
|
43
|
+
import { type SecretBoxKeyring } from '@aglyn/shared-util-tools/secret-box';
|
|
44
|
+
import type { PluginConnectionDeclaration } from '@aglyn/aglyn/app-utils/plugin-connections';
|
|
45
|
+
import { type PluginConnectionChoice, type PluginConnectionStatus, type PluginConnectionView } from '../../model/plugin-connections';
|
|
46
|
+
import { type PluginConnectionClient, type PluginConnectionTokenSet } from './provider-http';
|
|
47
|
+
/** The stored document. Server-only. */
|
|
48
|
+
export interface PluginConnectionRecord {
|
|
49
|
+
/** Tells a connection from a pending connect's state record in the same collection. */
|
|
50
|
+
kind: 'connection';
|
|
51
|
+
hostId: string;
|
|
52
|
+
/** The workspace that owns the site, when it connected. */
|
|
53
|
+
orgId: string;
|
|
54
|
+
listingId: string;
|
|
55
|
+
connectionId: string;
|
|
56
|
+
provider: string;
|
|
57
|
+
status: PluginConnectionStatus;
|
|
58
|
+
/** `''` when the provider issued no refresh token. */
|
|
59
|
+
sealedRefreshToken: string;
|
|
60
|
+
sealedAccessToken: string;
|
|
61
|
+
accessExpiresAtMs: number;
|
|
62
|
+
refreshedAtMs: number;
|
|
63
|
+
refreshLeaseUntilMs: number;
|
|
64
|
+
scopes: string[];
|
|
65
|
+
/** What the owner chose, by resource id. */
|
|
66
|
+
choices: Record<string, PluginConnectionChoice>;
|
|
67
|
+
connectedAtMs: number;
|
|
68
|
+
connectedByUid: string;
|
|
69
|
+
lastSyncAtMs: number | null;
|
|
70
|
+
/** When the scheduled sync should next run this connection; `null` while it waits for choices. */
|
|
71
|
+
nextSyncAtMs: number | null;
|
|
72
|
+
lastError: string | null;
|
|
73
|
+
/** Scheduled syncs that failed in a row since the last one that ran; sets the backoff (AGL-4194). */
|
|
74
|
+
syncFailures?: number;
|
|
75
|
+
updatedAtMs: number;
|
|
76
|
+
}
|
|
77
|
+
export declare function connectionsCollection(firestore: FirebaseFirestore.Firestore, hostId: string): FirebaseFirestore.CollectionReference;
|
|
78
|
+
export declare function connectionRef(firestore: FirebaseFirestore.Firestore, hostId: string, listingId: string, connectionId: string): FirebaseFirestore.DocumentReference;
|
|
79
|
+
/** The record the connection's sync writes, beside it (AGL-4192, AGL-4194). Server-only, like the rest. */
|
|
80
|
+
export declare function connectionRecordRef(firestore: FirebaseFirestore.Firestore, hostId: string, listingId: string, connectionId: string): FirebaseFirestore.DocumentReference;
|
|
81
|
+
type TokenOwner = Pick<PluginConnectionRecord, 'hostId' | 'listingId' | 'connectionId'>;
|
|
82
|
+
/** The seal's context: this document and this token's purpose. */
|
|
83
|
+
export declare const tokenContext: (owner: TokenOwner, purpose: "refresh" | "access") => string;
|
|
84
|
+
/** Seals a token set for a record; a missing refresh token keeps `previousRefresh` (already sealed). */
|
|
85
|
+
export declare function sealTokens(keyring: SecretBoxKeyring, owner: TokenOwner, tokens: Pick<PluginConnectionTokenSet, 'accessToken' | 'refreshToken'>, previousRefresh?: string): Pick<PluginConnectionRecord, 'sealedRefreshToken' | 'sealedAccessToken'>;
|
|
86
|
+
/** Opens one of a record's tokens; throws `SecretBoxError` for one that does not open. */
|
|
87
|
+
export declare function openToken(keyring: SecretBoxKeyring, record: TokenOwner & Pick<PluginConnectionRecord, 'sealedRefreshToken' | 'sealedAccessToken'>, purpose: 'refresh' | 'access'): {
|
|
88
|
+
token: string;
|
|
89
|
+
reseal: boolean;
|
|
90
|
+
};
|
|
91
|
+
/** A stored document read defensively, or `null` for anything that is not a connection. */
|
|
92
|
+
export declare function readConnection(data: Record<string, unknown> | undefined): PluginConnectionRecord | null;
|
|
93
|
+
export declare function getConnection(firestore: FirebaseFirestore.Firestore, hostId: string, listingId: string, connectionId: string): Promise<PluginConnectionRecord | null>;
|
|
94
|
+
/** Every connection a site holds (never the pending states). */
|
|
95
|
+
export declare function listHostConnections(firestore: FirebaseFirestore.Firestore, hostId: string): Promise<PluginConnectionRecord[]>;
|
|
96
|
+
/** Whether every resource the declaration lists has a choice. */
|
|
97
|
+
export declare function connectionReady(record: Pick<PluginConnectionRecord, 'choices'>, declaration: Pick<PluginConnectionDeclaration, 'resources'> | null): boolean;
|
|
98
|
+
/** What the console reads: no token, no lease, no uid. */
|
|
99
|
+
export declare function toConnectionView(record: PluginConnectionRecord, declaration: Pick<PluginConnectionDeclaration, 'resources'> | null): PluginConnectionView;
|
|
100
|
+
/**
|
|
101
|
+
* Builds the record for a freshly exchanged grant. Reconnecting keeps the
|
|
102
|
+
* earlier choices and sync history: the owner reconnecting after a revoked
|
|
103
|
+
* grant means the same account, and the choose route re-checks every choice
|
|
104
|
+
* against what the new grant reaches before it is used again.
|
|
105
|
+
*/
|
|
106
|
+
export declare function newConnectionRecord(input: {
|
|
107
|
+
keyring: SecretBoxKeyring;
|
|
108
|
+
hostId: string;
|
|
109
|
+
orgId: string;
|
|
110
|
+
listingId: string;
|
|
111
|
+
connection: PluginConnectionDeclaration;
|
|
112
|
+
tokens: PluginConnectionTokenSet;
|
|
113
|
+
uid: string;
|
|
114
|
+
nowMs: number;
|
|
115
|
+
previous: PluginConnectionRecord | null;
|
|
116
|
+
}): PluginConnectionRecord;
|
|
117
|
+
/** Refresh when the access token has less than this left. */
|
|
118
|
+
export declare const ACCESS_REFRESH_MARGIN_MS: number;
|
|
119
|
+
export interface PluginConnectionTokenDeps {
|
|
120
|
+
firestore: FirebaseFirestore.Firestore;
|
|
121
|
+
keyring: SecretBoxKeyring;
|
|
122
|
+
/** The declaration the record was made under (the site's pinned manifest). */
|
|
123
|
+
connection: PluginConnectionDeclaration;
|
|
124
|
+
/** The publisher's OAuth app, opened. */
|
|
125
|
+
client: PluginConnectionClient;
|
|
126
|
+
now: () => number;
|
|
127
|
+
sleep?: (ms: number) => Promise<void>;
|
|
128
|
+
}
|
|
129
|
+
/** Thrown when a grant can no longer be used and the site owner has to connect again. */
|
|
130
|
+
export declare class PluginConnectionReconnectRequiredError extends Error {
|
|
131
|
+
constructor(message: string);
|
|
132
|
+
}
|
|
133
|
+
/** Whether a record's access token should be refreshed before use. */
|
|
134
|
+
export declare function accessTokenStale(record: Pick<PluginConnectionRecord, 'sealedAccessToken' | 'accessExpiresAtMs'>, nowMs: number): boolean;
|
|
135
|
+
/**
|
|
136
|
+
* A usable access token for a connected record: the stored one, or a fresh
|
|
137
|
+
* one under the lease when it is near expiry (or `force`, after a 401).
|
|
138
|
+
*/
|
|
139
|
+
export declare function connectionAccessToken(deps: PluginConnectionTokenDeps, record: PluginConnectionRecord, options?: {
|
|
140
|
+
force?: boolean;
|
|
141
|
+
}): Promise<string>;
|
|
142
|
+
/**
|
|
143
|
+
* Refreshes a grant under the lease and stores the rotated tokens. Answers
|
|
144
|
+
* the access token now in force — this refresh's, or a concurrent one's.
|
|
145
|
+
*/
|
|
146
|
+
export declare function refreshConnection(deps: PluginConnectionTokenDeps, record: PluginConnectionRecord): Promise<{
|
|
147
|
+
accessToken: string;
|
|
148
|
+
refreshed: boolean;
|
|
149
|
+
}>;
|
|
150
|
+
/**
|
|
151
|
+
* Records a sync that ran (for the scheduled sync): its time, the next run,
|
|
152
|
+
* and the status back to `connected` with no error. `update()`, never a
|
|
153
|
+
* merge: a connection disconnected while the sync ran must not come back as
|
|
154
|
+
* a stub.
|
|
155
|
+
*/
|
|
156
|
+
export declare function markConnectionSynced(firestore: FirebaseFirestore.Firestore, record: TokenOwner, input: {
|
|
157
|
+
nowMs: number;
|
|
158
|
+
nextSyncAtMs: number;
|
|
159
|
+
}): Promise<void>;
|
|
160
|
+
/**
|
|
161
|
+
* Records a failure (for the scheduled sync and the picker): `error` with the
|
|
162
|
+
* next try scheduled, or `reconnect_required`, which stops the schedule until
|
|
163
|
+
* the owner connects again. The message is the provider's sentence or ours,
|
|
164
|
+
* never a token.
|
|
165
|
+
*/
|
|
166
|
+
export declare function markConnectionError(firestore: FirebaseFirestore.Firestore, record: TokenOwner, input: {
|
|
167
|
+
nowMs: number;
|
|
168
|
+
message: string;
|
|
169
|
+
reconnect: boolean;
|
|
170
|
+
nextSyncAtMs?: number | null;
|
|
171
|
+
syncFailures?: number;
|
|
172
|
+
}): Promise<void>;
|
|
173
|
+
export {};
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ /**
|
|
18
|
+
* A SITE'S DECLARED CONNECTION, AND ITS SEALED GRANT (AGL-4193).
|
|
19
|
+
*
|
|
20
|
+
* `hosts/{hostId}/pluginConnections/{listingId}__{connectionId}` — one per
|
|
21
|
+
* site, listing and declared connection. Copied in shape from accounting's
|
|
22
|
+
* ledger connection (`libs/plugins/accounting/.../connection-store.ts`), not
|
|
23
|
+
* imported: one plugin never imports another.
|
|
24
|
+
*
|
|
25
|
+
* The refresh and access tokens are sealed with `PLUGIN_CONNECTIONS_TOKEN_KEY`
|
|
26
|
+
* (secret box, AES-256-GCM), each bound to this document and its purpose as
|
|
27
|
+
* the seal's context, so a value copied onto another site's document refuses
|
|
28
|
+
* to open there. The rules refuse every client the whole collection; the
|
|
29
|
+
* console reads {@link toConnectionView}, which carries no token, through the
|
|
30
|
+
* status route.
|
|
31
|
+
*
|
|
32
|
+
* ## Rotation, and only one refresher
|
|
33
|
+
*
|
|
34
|
+
* Many providers hand back a new refresh token on a refresh and retire the
|
|
35
|
+
* old one soon after. Two processes refreshing the same grant at once — the
|
|
36
|
+
* picker and the scheduled sync — would each spend the old token, and one
|
|
37
|
+
* would store a token the provider has already replaced. So a refresh first
|
|
38
|
+
* takes a short LEASE on the document in a transaction; a second refresher
|
|
39
|
+
* that finds the lease held waits for the first's result instead. A provider
|
|
40
|
+
* that refuses the grant (`invalid_grant`, a 401) moves the connection to
|
|
41
|
+
* `reconnect_required`, which only the site owner connecting again clears.
|
|
42
|
+
*/ import { needsReseal, openSecret, sealSecret } from "@aglyn/shared-util-tools/secret-box";
|
|
43
|
+
import { pluginConnectionDocId, pluginConnectionRecordDocId } from "../../model/plugin-connections.js";
|
|
44
|
+
import { PluginConnectionProviderError, refreshPluginConnectionToken } from "./provider-http.js";
|
|
45
|
+
export function connectionsCollection(firestore, hostId) {
|
|
46
|
+
// Spelled out, not `PLUGIN_CONNECTIONS_HOST_COLLECTION`: the repo's host
|
|
47
|
+
// collection guards find a site's collections by this literal shape.
|
|
48
|
+
return firestore.collection('hosts').doc(hostId).collection('pluginConnections');
|
|
49
|
+
}
|
|
50
|
+
export function connectionRef(firestore, hostId, listingId, connectionId) {
|
|
51
|
+
return connectionsCollection(firestore, hostId).doc(pluginConnectionDocId(listingId, connectionId));
|
|
52
|
+
}
|
|
53
|
+
/** The record the connection's sync writes, beside it (AGL-4192, AGL-4194). Server-only, like the rest. */ export function connectionRecordRef(firestore, hostId, listingId, connectionId) {
|
|
54
|
+
return connectionsCollection(firestore, hostId).doc(pluginConnectionRecordDocId(listingId, connectionId));
|
|
55
|
+
}
|
|
56
|
+
/** The seal's context: this document and this token's purpose. */ export const tokenContext = (owner, purpose)=>`plugin-connections:token:${owner.hostId}:${owner.listingId}:${owner.connectionId}:${purpose}`;
|
|
57
|
+
/** Seals a token set for a record; a missing refresh token keeps `previousRefresh` (already sealed). */ export function sealTokens(keyring, owner, tokens, previousRefresh = '') {
|
|
58
|
+
return {
|
|
59
|
+
sealedRefreshToken: tokens.refreshToken ? sealSecret(tokens.refreshToken, keyring.current, {
|
|
60
|
+
context: tokenContext(owner, 'refresh')
|
|
61
|
+
}) : previousRefresh,
|
|
62
|
+
sealedAccessToken: sealSecret(tokens.accessToken, keyring.current, {
|
|
63
|
+
context: tokenContext(owner, 'access')
|
|
64
|
+
})
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/** Opens one of a record's tokens; throws `SecretBoxError` for one that does not open. */ export function openToken(keyring, record, purpose) {
|
|
68
|
+
const sealed = purpose === 'refresh' ? record.sealedRefreshToken : record.sealedAccessToken;
|
|
69
|
+
const opened = openSecret(sealed, keyring, {
|
|
70
|
+
context: tokenContext(record, purpose)
|
|
71
|
+
});
|
|
72
|
+
return {
|
|
73
|
+
token: opened.plaintext,
|
|
74
|
+
reseal: needsReseal(opened, keyring)
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const STATUSES = [
|
|
78
|
+
'connected',
|
|
79
|
+
'reconnect_required',
|
|
80
|
+
'error'
|
|
81
|
+
];
|
|
82
|
+
/** A stored document read defensively, or `null` for anything that is not a connection. */ export function readConnection(data) {
|
|
83
|
+
if (!data || data['kind'] !== 'connection' || typeof data['listingId'] !== 'string' || typeof data['connectionId'] !== 'string') {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
const record = data;
|
|
87
|
+
const choices = {};
|
|
88
|
+
if (record.choices && typeof record.choices === 'object') {
|
|
89
|
+
for (const [key, value] of Object.entries(record.choices)){
|
|
90
|
+
var _value_label;
|
|
91
|
+
if (value && typeof value.id === 'string') choices[key] = {
|
|
92
|
+
id: value.id,
|
|
93
|
+
label: String((_value_label = value.label) != null ? _value_label : value.id)
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return _extends({}, record, {
|
|
98
|
+
status: STATUSES.includes(record.status) ? record.status : 'error',
|
|
99
|
+
sealedRefreshToken: typeof record.sealedRefreshToken === 'string' ? record.sealedRefreshToken : '',
|
|
100
|
+
sealedAccessToken: typeof record.sealedAccessToken === 'string' ? record.sealedAccessToken : '',
|
|
101
|
+
accessExpiresAtMs: Number(record.accessExpiresAtMs) || 0,
|
|
102
|
+
refreshedAtMs: Number(record.refreshedAtMs) || 0,
|
|
103
|
+
refreshLeaseUntilMs: Number(record.refreshLeaseUntilMs) || 0,
|
|
104
|
+
scopes: Array.isArray(record.scopes) ? record.scopes.map(String) : [],
|
|
105
|
+
choices,
|
|
106
|
+
lastSyncAtMs: Number(record.lastSyncAtMs) || null,
|
|
107
|
+
nextSyncAtMs: Number(record.nextSyncAtMs) || null,
|
|
108
|
+
lastError: typeof record.lastError === 'string' ? record.lastError : null,
|
|
109
|
+
syncFailures: Math.max(0, Math.trunc(Number(record.syncFailures) || 0))
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
export async function getConnection(firestore, hostId, listingId, connectionId) {
|
|
113
|
+
const snapshot = await connectionRef(firestore, hostId, listingId, connectionId).get();
|
|
114
|
+
return snapshot.exists ? readConnection(snapshot.data()) : null;
|
|
115
|
+
}
|
|
116
|
+
/** Every connection a site holds (never the pending states). */ export async function listHostConnections(firestore, hostId) {
|
|
117
|
+
const snapshot = await connectionsCollection(firestore, hostId).where('kind', '==', 'connection').get();
|
|
118
|
+
return snapshot.docs.map((doc)=>readConnection(doc.data())).filter((record)=>Boolean(record));
|
|
119
|
+
}
|
|
120
|
+
/** Whether every resource the declaration lists has a choice. */ export function connectionReady(record, declaration) {
|
|
121
|
+
var _ref;
|
|
122
|
+
return ((_ref = declaration == null ? void 0 : declaration.resources) != null ? _ref : []).every((resource)=>Boolean(record.choices[resource.id]));
|
|
123
|
+
}
|
|
124
|
+
/** What the console reads: no token, no lease, no uid. */ export function toConnectionView(record, declaration) {
|
|
125
|
+
return {
|
|
126
|
+
listingId: record.listingId,
|
|
127
|
+
connectionId: record.connectionId,
|
|
128
|
+
provider: record.provider,
|
|
129
|
+
status: record.status,
|
|
130
|
+
choices: record.choices,
|
|
131
|
+
ready: connectionReady(record, declaration),
|
|
132
|
+
scopes: record.scopes,
|
|
133
|
+
connectedAtMs: record.connectedAtMs,
|
|
134
|
+
lastSyncAtMs: record.lastSyncAtMs,
|
|
135
|
+
lastError: record.lastError
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Builds the record for a freshly exchanged grant. Reconnecting keeps the
|
|
140
|
+
* earlier choices and sync history: the owner reconnecting after a revoked
|
|
141
|
+
* grant means the same account, and the choose route re-checks every choice
|
|
142
|
+
* against what the new grant reaches before it is used again.
|
|
143
|
+
*/ export function newConnectionRecord(input) {
|
|
144
|
+
var _ref, _ref1, _ref2;
|
|
145
|
+
var _input_previous, _input_previous1, _input_previous2;
|
|
146
|
+
const owner = {
|
|
147
|
+
hostId: input.hostId,
|
|
148
|
+
listingId: input.listingId,
|
|
149
|
+
connectionId: input.connection.id
|
|
150
|
+
};
|
|
151
|
+
const choices = (_ref = (_input_previous = input.previous) == null ? void 0 : _input_previous.choices) != null ? _ref : {};
|
|
152
|
+
const ready = connectionReady({
|
|
153
|
+
choices
|
|
154
|
+
}, input.connection);
|
|
155
|
+
return _extends({
|
|
156
|
+
kind: 'connection'
|
|
157
|
+
}, owner, {
|
|
158
|
+
orgId: input.orgId,
|
|
159
|
+
provider: input.connection.provider,
|
|
160
|
+
status: 'connected'
|
|
161
|
+
}, sealTokens(input.keyring, owner, input.tokens, (_ref1 = (_input_previous1 = input.previous) == null ? void 0 : _input_previous1.sealedRefreshToken) != null ? _ref1 : ''), {
|
|
162
|
+
accessExpiresAtMs: input.tokens.accessExpiresAtMs,
|
|
163
|
+
refreshedAtMs: input.nowMs,
|
|
164
|
+
refreshLeaseUntilMs: 0,
|
|
165
|
+
scopes: input.tokens.scopes,
|
|
166
|
+
choices,
|
|
167
|
+
connectedAtMs: input.nowMs,
|
|
168
|
+
connectedByUid: input.uid,
|
|
169
|
+
lastSyncAtMs: (_ref2 = (_input_previous2 = input.previous) == null ? void 0 : _input_previous2.lastSyncAtMs) != null ? _ref2 : null,
|
|
170
|
+
nextSyncAtMs: ready ? input.nowMs : null,
|
|
171
|
+
lastError: null,
|
|
172
|
+
updatedAtMs: input.nowMs
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
/** Refresh when the access token has less than this left. */ export const ACCESS_REFRESH_MARGIN_MS = 5 * 60 * 1000;
|
|
176
|
+
const LEASE_MS = 30000;
|
|
177
|
+
const LEASE_WAITS = 6;
|
|
178
|
+
const LEASE_WAIT_MS = 1000;
|
|
179
|
+
/** Thrown when a grant can no longer be used and the site owner has to connect again. */ export class PluginConnectionReconnectRequiredError extends Error {
|
|
180
|
+
constructor(message){
|
|
181
|
+
super(message);
|
|
182
|
+
this.name = 'PluginConnectionReconnectRequiredError';
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/** Whether a record's access token should be refreshed before use. */ export function accessTokenStale(record, nowMs) {
|
|
186
|
+
return !record.sealedAccessToken || record.accessExpiresAtMs - ACCESS_REFRESH_MARGIN_MS <= nowMs;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* A usable access token for a connected record: the stored one, or a fresh
|
|
190
|
+
* one under the lease when it is near expiry (or `force`, after a 401).
|
|
191
|
+
*/ export async function connectionAccessToken(deps, record, options = {}) {
|
|
192
|
+
if (record.status === 'reconnect_required') {
|
|
193
|
+
var _record_lastError;
|
|
194
|
+
throw new PluginConnectionReconnectRequiredError((_record_lastError = record.lastError) != null ? _record_lastError : 'Connect again.');
|
|
195
|
+
}
|
|
196
|
+
if (!options.force && !accessTokenStale(record, deps.now())) {
|
|
197
|
+
return openToken(deps.keyring, record, 'access').token;
|
|
198
|
+
}
|
|
199
|
+
return (await refreshConnection(deps, record)).accessToken;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Refreshes a grant under the lease and stores the rotated tokens. Answers
|
|
203
|
+
* the access token now in force — this refresh's, or a concurrent one's.
|
|
204
|
+
*/ export async function refreshConnection(deps, record) {
|
|
205
|
+
var _deps_sleep;
|
|
206
|
+
const ref = connectionRef(deps.firestore, record.hostId, record.listingId, record.connectionId);
|
|
207
|
+
const sleep = (_deps_sleep = deps.sleep) != null ? _deps_sleep : (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
|
|
208
|
+
const startedRefreshedAt = record.refreshedAtMs;
|
|
209
|
+
for(let wait = 0; wait <= LEASE_WAITS; wait += 1){
|
|
210
|
+
const claim = await deps.firestore.runTransaction(async (transaction)=>{
|
|
211
|
+
const snapshot = await transaction.get(ref);
|
|
212
|
+
const current = readConnection(snapshot.exists ? snapshot.data() : undefined);
|
|
213
|
+
if (!current || current.status === 'reconnect_required') return {
|
|
214
|
+
kind: 'gone',
|
|
215
|
+
current
|
|
216
|
+
};
|
|
217
|
+
// Someone else refreshed since we read: use theirs.
|
|
218
|
+
if (current.refreshedAtMs > startedRefreshedAt && !accessTokenStale(current, deps.now())) {
|
|
219
|
+
return {
|
|
220
|
+
kind: 'fresh',
|
|
221
|
+
current
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
if (current.refreshLeaseUntilMs > deps.now()) return {
|
|
225
|
+
kind: 'held',
|
|
226
|
+
current
|
|
227
|
+
};
|
|
228
|
+
transaction.update(ref, {
|
|
229
|
+
refreshLeaseUntilMs: deps.now() + LEASE_MS
|
|
230
|
+
});
|
|
231
|
+
return {
|
|
232
|
+
kind: 'ours',
|
|
233
|
+
current
|
|
234
|
+
};
|
|
235
|
+
});
|
|
236
|
+
if (claim.kind === 'gone') {
|
|
237
|
+
var _ref;
|
|
238
|
+
var _claim_current;
|
|
239
|
+
throw new PluginConnectionReconnectRequiredError((_ref = (_claim_current = claim.current) == null ? void 0 : _claim_current.lastError) != null ? _ref : 'Connect again.');
|
|
240
|
+
}
|
|
241
|
+
if (claim.kind === 'fresh') {
|
|
242
|
+
return {
|
|
243
|
+
accessToken: openToken(deps.keyring, claim.current, 'access').token,
|
|
244
|
+
refreshed: false
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
if (claim.kind === 'held') {
|
|
248
|
+
await sleep(LEASE_WAIT_MS);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
const current = claim.current;
|
|
252
|
+
if (!current.sealedRefreshToken) {
|
|
253
|
+
// Nothing to refresh with: the provider never issued a refresh token.
|
|
254
|
+
const message = 'The connection has expired. Connect again.';
|
|
255
|
+
await ref.update({
|
|
256
|
+
status: 'reconnect_required',
|
|
257
|
+
lastError: message,
|
|
258
|
+
refreshLeaseUntilMs: 0,
|
|
259
|
+
nextSyncAtMs: null,
|
|
260
|
+
updatedAtMs: deps.now()
|
|
261
|
+
});
|
|
262
|
+
throw new PluginConnectionReconnectRequiredError(message);
|
|
263
|
+
}
|
|
264
|
+
let tokens;
|
|
265
|
+
try {
|
|
266
|
+
tokens = await refreshPluginConnectionToken(deps.connection, deps.client, openToken(deps.keyring, current, 'refresh').token);
|
|
267
|
+
} catch (error) {
|
|
268
|
+
if (error instanceof PluginConnectionProviderError && error.code === 'auth') {
|
|
269
|
+
const message = error.message || 'The provider refused the connection. Connect again.';
|
|
270
|
+
await ref.update({
|
|
271
|
+
status: 'reconnect_required',
|
|
272
|
+
lastError: message,
|
|
273
|
+
refreshLeaseUntilMs: 0,
|
|
274
|
+
nextSyncAtMs: null,
|
|
275
|
+
updatedAtMs: deps.now()
|
|
276
|
+
});
|
|
277
|
+
throw new PluginConnectionReconnectRequiredError(message);
|
|
278
|
+
}
|
|
279
|
+
await ref.update({
|
|
280
|
+
refreshLeaseUntilMs: 0
|
|
281
|
+
});
|
|
282
|
+
throw error;
|
|
283
|
+
}
|
|
284
|
+
const nowMs = deps.now();
|
|
285
|
+
await ref.update(_extends({}, sealTokens(deps.keyring, current, tokens, current.sealedRefreshToken), {
|
|
286
|
+
accessExpiresAtMs: tokens.accessExpiresAtMs,
|
|
287
|
+
refreshedAtMs: nowMs,
|
|
288
|
+
refreshLeaseUntilMs: 0,
|
|
289
|
+
updatedAtMs: nowMs
|
|
290
|
+
}));
|
|
291
|
+
return {
|
|
292
|
+
accessToken: tokens.accessToken,
|
|
293
|
+
refreshed: true
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
throw new PluginConnectionProviderError('transient', 'Another refresh of this connection is still running.');
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Records a sync that ran (for the scheduled sync): its time, the next run,
|
|
300
|
+
* and the status back to `connected` with no error. `update()`, never a
|
|
301
|
+
* merge: a connection disconnected while the sync ran must not come back as
|
|
302
|
+
* a stub.
|
|
303
|
+
*/ export async function markConnectionSynced(firestore, record, input) {
|
|
304
|
+
await connectionRef(firestore, record.hostId, record.listingId, record.connectionId).update({
|
|
305
|
+
status: 'connected',
|
|
306
|
+
lastSyncAtMs: input.nowMs,
|
|
307
|
+
nextSyncAtMs: input.nextSyncAtMs,
|
|
308
|
+
lastError: null,
|
|
309
|
+
syncFailures: 0,
|
|
310
|
+
updatedAtMs: input.nowMs
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Records a failure (for the scheduled sync and the picker): `error` with the
|
|
315
|
+
* next try scheduled, or `reconnect_required`, which stops the schedule until
|
|
316
|
+
* the owner connects again. The message is the provider's sentence or ours,
|
|
317
|
+
* never a token.
|
|
318
|
+
*/ export async function markConnectionError(firestore, record, input) {
|
|
319
|
+
var _input_nextSyncAtMs;
|
|
320
|
+
await connectionRef(firestore, record.hostId, record.listingId, record.connectionId).update(_extends({
|
|
321
|
+
status: input.reconnect ? 'reconnect_required' : 'error',
|
|
322
|
+
lastError: input.message.slice(0, 300),
|
|
323
|
+
nextSyncAtMs: input.reconnect ? null : (_input_nextSyncAtMs = input.nextSyncAtMs) != null ? _input_nextSyncAtMs : null
|
|
324
|
+
}, input.syncFailures !== undefined ? {
|
|
325
|
+
syncFailures: input.syncFailures
|
|
326
|
+
} : {}, {
|
|
327
|
+
updatedAtMs: input.nowMs
|
|
328
|
+
}));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
//# sourceMappingURL=connection-store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/plugins/marketplace/src/lib/server/connections/connection-store.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * A SITE'S DECLARED CONNECTION, AND ITS SEALED GRANT (AGL-4193).\n *\n * `hosts/{hostId}/pluginConnections/{listingId}__{connectionId}` — one per\n * site, listing and declared connection. Copied in shape from accounting's\n * ledger connection (`libs/plugins/accounting/.../connection-store.ts`), not\n * imported: one plugin never imports another.\n *\n * The refresh and access tokens are sealed with `PLUGIN_CONNECTIONS_TOKEN_KEY`\n * (secret box, AES-256-GCM), each bound to this document and its purpose as\n * the seal's context, so a value copied onto another site's document refuses\n * to open there. The rules refuse every client the whole collection; the\n * console reads {@link toConnectionView}, which carries no token, through the\n * status route.\n *\n * ## Rotation, and only one refresher\n *\n * Many providers hand back a new refresh token on a refresh and retire the\n * old one soon after. Two processes refreshing the same grant at once — the\n * picker and the scheduled sync — would each spend the old token, and one\n * would store a token the provider has already replaced. So a refresh first\n * takes a short LEASE on the document in a transaction; a second refresher\n * that finds the lease held waits for the first's result instead. A provider\n * that refuses the grant (`invalid_grant`, a 401) moves the connection to\n * `reconnect_required`, which only the site owner connecting again clears.\n */\n\nimport { needsReseal, openSecret, sealSecret, type SecretBoxKeyring } from '@aglyn/shared-util-tools/secret-box'\nimport type { PluginConnectionDeclaration } from '@aglyn/aglyn/app-utils/plugin-connections'\nimport {\n pluginConnectionDocId,\n pluginConnectionRecordDocId,\n type PluginConnectionChoice,\n type PluginConnectionStatus,\n type PluginConnectionView,\n} from '../../model/plugin-connections'\nimport {\n PluginConnectionProviderError,\n refreshPluginConnectionToken,\n type PluginConnectionClient,\n type PluginConnectionTokenSet,\n} from './provider-http'\n\n/** The stored document. Server-only. */\nexport interface PluginConnectionRecord {\n /** Tells a connection from a pending connect's state record in the same collection. */\n kind: 'connection'\n hostId: string\n /** The workspace that owns the site, when it connected. */\n orgId: string\n listingId: string\n connectionId: string\n provider: string\n status: PluginConnectionStatus\n /** `''` when the provider issued no refresh token. */\n sealedRefreshToken: string\n sealedAccessToken: string\n accessExpiresAtMs: number\n refreshedAtMs: number\n refreshLeaseUntilMs: number\n scopes: string[]\n /** What the owner chose, by resource id. */\n choices: Record<string, PluginConnectionChoice>\n connectedAtMs: number\n connectedByUid: string\n lastSyncAtMs: number | null\n /** When the scheduled sync should next run this connection; `null` while it waits for choices. */\n nextSyncAtMs: number | null\n lastError: string | null\n /** Scheduled syncs that failed in a row since the last one that ran; sets the backoff (AGL-4194). */\n syncFailures?: number\n updatedAtMs: number\n}\n\nexport function connectionsCollection(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n): FirebaseFirestore.CollectionReference {\n // Spelled out, not `PLUGIN_CONNECTIONS_HOST_COLLECTION`: the repo's host\n // collection guards find a site's collections by this literal shape.\n return firestore.collection('hosts').doc(hostId).collection('pluginConnections')\n}\n\nexport function connectionRef(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n listingId: string,\n connectionId: string,\n): FirebaseFirestore.DocumentReference {\n return connectionsCollection(firestore, hostId).doc(pluginConnectionDocId(listingId, connectionId))\n}\n\n/** The record the connection's sync writes, beside it (AGL-4192, AGL-4194). Server-only, like the rest. */\nexport function connectionRecordRef(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n listingId: string,\n connectionId: string,\n): FirebaseFirestore.DocumentReference {\n return connectionsCollection(firestore, hostId).doc(pluginConnectionRecordDocId(listingId, connectionId))\n}\n\ntype TokenOwner = Pick<PluginConnectionRecord, 'hostId' | 'listingId' | 'connectionId'>\n\n/** The seal's context: this document and this token's purpose. */\nexport const tokenContext = (owner: TokenOwner, purpose: 'refresh' | 'access') =>\n `plugin-connections:token:${owner.hostId}:${owner.listingId}:${owner.connectionId}:${purpose}`\n\n/** Seals a token set for a record; a missing refresh token keeps `previousRefresh` (already sealed). */\nexport function sealTokens(\n keyring: SecretBoxKeyring,\n owner: TokenOwner,\n tokens: Pick<PluginConnectionTokenSet, 'accessToken' | 'refreshToken'>,\n previousRefresh = '',\n): Pick<PluginConnectionRecord, 'sealedRefreshToken' | 'sealedAccessToken'> {\n return {\n sealedRefreshToken: tokens.refreshToken\n ? sealSecret(tokens.refreshToken, keyring.current, { context: tokenContext(owner, 'refresh') })\n : previousRefresh,\n sealedAccessToken: sealSecret(tokens.accessToken, keyring.current, { context: tokenContext(owner, 'access') }),\n }\n}\n\n/** Opens one of a record's tokens; throws `SecretBoxError` for one that does not open. */\nexport function openToken(\n keyring: SecretBoxKeyring,\n record: TokenOwner & Pick<PluginConnectionRecord, 'sealedRefreshToken' | 'sealedAccessToken'>,\n purpose: 'refresh' | 'access',\n): { token: string; reseal: boolean } {\n const sealed = purpose === 'refresh' ? record.sealedRefreshToken : record.sealedAccessToken\n const opened = openSecret(sealed, keyring, { context: tokenContext(record, purpose) })\n return { token: opened.plaintext, reseal: needsReseal(opened, keyring) }\n}\n\nconst STATUSES: readonly PluginConnectionStatus[] = ['connected', 'reconnect_required', 'error']\n\n/** A stored document read defensively, or `null` for anything that is not a connection. */\nexport function readConnection(data: Record<string, unknown> | undefined): PluginConnectionRecord | null {\n if (!data || data['kind'] !== 'connection' || typeof data['listingId'] !== 'string' || typeof data['connectionId'] !== 'string') {\n return null\n }\n const record = data as unknown as PluginConnectionRecord\n const choices: Record<string, PluginConnectionChoice> = {}\n if (record.choices && typeof record.choices === 'object') {\n for (const [key, value] of Object.entries(record.choices)) {\n if (value && typeof value.id === 'string') choices[key] = { id: value.id, label: String(value.label ?? value.id) }\n }\n }\n return {\n ...record,\n status: STATUSES.includes(record.status) ? record.status : 'error',\n sealedRefreshToken: typeof record.sealedRefreshToken === 'string' ? record.sealedRefreshToken : '',\n sealedAccessToken: typeof record.sealedAccessToken === 'string' ? record.sealedAccessToken : '',\n accessExpiresAtMs: Number(record.accessExpiresAtMs) || 0,\n refreshedAtMs: Number(record.refreshedAtMs) || 0,\n refreshLeaseUntilMs: Number(record.refreshLeaseUntilMs) || 0,\n scopes: Array.isArray(record.scopes) ? record.scopes.map(String) : [],\n choices,\n lastSyncAtMs: Number(record.lastSyncAtMs) || null,\n nextSyncAtMs: Number(record.nextSyncAtMs) || null,\n lastError: typeof record.lastError === 'string' ? record.lastError : null,\n syncFailures: Math.max(0, Math.trunc(Number(record.syncFailures) || 0)),\n }\n}\n\nexport async function getConnection(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n listingId: string,\n connectionId: string,\n): Promise<PluginConnectionRecord | null> {\n const snapshot = await connectionRef(firestore, hostId, listingId, connectionId).get()\n return snapshot.exists ? readConnection(snapshot.data()) : null\n}\n\n/** Every connection a site holds (never the pending states). */\nexport async function listHostConnections(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n): Promise<PluginConnectionRecord[]> {\n const snapshot = await connectionsCollection(firestore, hostId).where('kind', '==', 'connection').get()\n return snapshot.docs\n .map((doc) => readConnection(doc.data()))\n .filter((record): record is PluginConnectionRecord => Boolean(record))\n}\n\n/** Whether every resource the declaration lists has a choice. */\nexport function connectionReady(\n record: Pick<PluginConnectionRecord, 'choices'>,\n declaration: Pick<PluginConnectionDeclaration, 'resources'> | null,\n): boolean {\n return (declaration?.resources ?? []).every((resource) => Boolean(record.choices[resource.id]))\n}\n\n/** What the console reads: no token, no lease, no uid. */\nexport function toConnectionView(\n record: PluginConnectionRecord,\n declaration: Pick<PluginConnectionDeclaration, 'resources'> | null,\n): PluginConnectionView {\n return {\n listingId: record.listingId,\n connectionId: record.connectionId,\n provider: record.provider,\n status: record.status,\n choices: record.choices,\n ready: connectionReady(record, declaration),\n scopes: record.scopes,\n connectedAtMs: record.connectedAtMs,\n lastSyncAtMs: record.lastSyncAtMs,\n lastError: record.lastError,\n }\n}\n\n/**\n * Builds the record for a freshly exchanged grant. Reconnecting keeps the\n * earlier choices and sync history: the owner reconnecting after a revoked\n * grant means the same account, and the choose route re-checks every choice\n * against what the new grant reaches before it is used again.\n */\nexport function newConnectionRecord(input: {\n keyring: SecretBoxKeyring\n hostId: string\n orgId: string\n listingId: string\n connection: PluginConnectionDeclaration\n tokens: PluginConnectionTokenSet\n uid: string\n nowMs: number\n previous: PluginConnectionRecord | null\n}): PluginConnectionRecord {\n const owner = { hostId: input.hostId, listingId: input.listingId, connectionId: input.connection.id }\n const choices = input.previous?.choices ?? {}\n const ready = connectionReady({ choices }, input.connection)\n return {\n kind: 'connection',\n ...owner,\n orgId: input.orgId,\n provider: input.connection.provider,\n status: 'connected',\n // A provider that issues no new refresh token on a re-consent keeps the\n // previous grant's, which it did not revoke.\n ...sealTokens(input.keyring, owner, input.tokens, input.previous?.sealedRefreshToken ?? ''),\n accessExpiresAtMs: input.tokens.accessExpiresAtMs,\n refreshedAtMs: input.nowMs,\n refreshLeaseUntilMs: 0,\n scopes: input.tokens.scopes,\n choices,\n connectedAtMs: input.nowMs,\n connectedByUid: input.uid,\n lastSyncAtMs: input.previous?.lastSyncAtMs ?? null,\n nextSyncAtMs: ready ? input.nowMs : null,\n lastError: null,\n updatedAtMs: input.nowMs,\n }\n}\n\n/** Refresh when the access token has less than this left. */\nexport const ACCESS_REFRESH_MARGIN_MS = 5 * 60 * 1000\n\nconst LEASE_MS = 30_000\nconst LEASE_WAITS = 6\nconst LEASE_WAIT_MS = 1000\n\nexport interface PluginConnectionTokenDeps {\n firestore: FirebaseFirestore.Firestore\n keyring: SecretBoxKeyring\n /** The declaration the record was made under (the site's pinned manifest). */\n connection: PluginConnectionDeclaration\n /** The publisher's OAuth app, opened. */\n client: PluginConnectionClient\n now: () => number\n sleep?: (ms: number) => Promise<void>\n}\n\n/** Thrown when a grant can no longer be used and the site owner has to connect again. */\nexport class PluginConnectionReconnectRequiredError extends Error {\n constructor(message: string) {\n super(message)\n this.name = 'PluginConnectionReconnectRequiredError'\n }\n}\n\n/** Whether a record's access token should be refreshed before use. */\nexport function accessTokenStale(record: Pick<PluginConnectionRecord, 'sealedAccessToken' | 'accessExpiresAtMs'>, nowMs: number): boolean {\n return !record.sealedAccessToken || record.accessExpiresAtMs - ACCESS_REFRESH_MARGIN_MS <= nowMs\n}\n\n/**\n * A usable access token for a connected record: the stored one, or a fresh\n * one under the lease when it is near expiry (or `force`, after a 401).\n */\nexport async function connectionAccessToken(\n deps: PluginConnectionTokenDeps,\n record: PluginConnectionRecord,\n options: { force?: boolean } = {},\n): Promise<string> {\n if (record.status === 'reconnect_required') {\n throw new PluginConnectionReconnectRequiredError(record.lastError ?? 'Connect again.')\n }\n if (!options.force && !accessTokenStale(record, deps.now())) {\n return openToken(deps.keyring, record, 'access').token\n }\n return (await refreshConnection(deps, record)).accessToken\n}\n\n/**\n * Refreshes a grant under the lease and stores the rotated tokens. Answers\n * the access token now in force — this refresh's, or a concurrent one's.\n */\nexport async function refreshConnection(\n deps: PluginConnectionTokenDeps,\n record: PluginConnectionRecord,\n): Promise<{ accessToken: string; refreshed: boolean }> {\n const ref = connectionRef(deps.firestore, record.hostId, record.listingId, record.connectionId)\n const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)))\n const startedRefreshedAt = record.refreshedAtMs\n for (let wait = 0; wait <= LEASE_WAITS; wait += 1) {\n const claim = await deps.firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const current = readConnection(snapshot.exists ? snapshot.data() : undefined)\n if (!current || current.status === 'reconnect_required') return { kind: 'gone' as const, current }\n // Someone else refreshed since we read: use theirs.\n if (current.refreshedAtMs > startedRefreshedAt && !accessTokenStale(current, deps.now())) {\n return { kind: 'fresh' as const, current }\n }\n if (current.refreshLeaseUntilMs > deps.now()) return { kind: 'held' as const, current }\n transaction.update(ref, { refreshLeaseUntilMs: deps.now() + LEASE_MS })\n return { kind: 'ours' as const, current }\n })\n if (claim.kind === 'gone') {\n throw new PluginConnectionReconnectRequiredError(claim.current?.lastError ?? 'Connect again.')\n }\n if (claim.kind === 'fresh') {\n return { accessToken: openToken(deps.keyring, claim.current, 'access').token, refreshed: false }\n }\n if (claim.kind === 'held') {\n await sleep(LEASE_WAIT_MS)\n continue\n }\n const current = claim.current\n if (!current.sealedRefreshToken) {\n // Nothing to refresh with: the provider never issued a refresh token.\n const message = 'The connection has expired. Connect again.'\n await ref.update({ status: 'reconnect_required', lastError: message, refreshLeaseUntilMs: 0, nextSyncAtMs: null, updatedAtMs: deps.now() })\n throw new PluginConnectionReconnectRequiredError(message)\n }\n let tokens: PluginConnectionTokenSet\n try {\n tokens = await refreshPluginConnectionToken(deps.connection, deps.client, openToken(deps.keyring, current, 'refresh').token)\n } catch (error) {\n if (error instanceof PluginConnectionProviderError && error.code === 'auth') {\n const message = error.message || 'The provider refused the connection. Connect again.'\n await ref.update({\n status: 'reconnect_required',\n lastError: message,\n refreshLeaseUntilMs: 0,\n nextSyncAtMs: null,\n updatedAtMs: deps.now(),\n })\n throw new PluginConnectionReconnectRequiredError(message)\n }\n await ref.update({ refreshLeaseUntilMs: 0 })\n throw error\n }\n const nowMs = deps.now()\n await ref.update({\n ...sealTokens(deps.keyring, current, tokens, current.sealedRefreshToken),\n accessExpiresAtMs: tokens.accessExpiresAtMs,\n refreshedAtMs: nowMs,\n refreshLeaseUntilMs: 0,\n updatedAtMs: nowMs,\n })\n return { accessToken: tokens.accessToken, refreshed: true }\n }\n throw new PluginConnectionProviderError('transient', 'Another refresh of this connection is still running.')\n}\n\n/**\n * Records a sync that ran (for the scheduled sync): its time, the next run,\n * and the status back to `connected` with no error. `update()`, never a\n * merge: a connection disconnected while the sync ran must not come back as\n * a stub.\n */\nexport async function markConnectionSynced(\n firestore: FirebaseFirestore.Firestore,\n record: TokenOwner,\n input: { nowMs: number; nextSyncAtMs: number },\n): Promise<void> {\n await connectionRef(firestore, record.hostId, record.listingId, record.connectionId).update({\n status: 'connected',\n lastSyncAtMs: input.nowMs,\n nextSyncAtMs: input.nextSyncAtMs,\n lastError: null,\n syncFailures: 0,\n updatedAtMs: input.nowMs,\n })\n}\n\n/**\n * Records a failure (for the scheduled sync and the picker): `error` with the\n * next try scheduled, or `reconnect_required`, which stops the schedule until\n * the owner connects again. The message is the provider's sentence or ours,\n * never a token.\n */\nexport async function markConnectionError(\n firestore: FirebaseFirestore.Firestore,\n record: TokenOwner,\n input: { nowMs: number; message: string; reconnect: boolean; nextSyncAtMs?: number | null; syncFailures?: number },\n): Promise<void> {\n await connectionRef(firestore, record.hostId, record.listingId, record.connectionId).update({\n status: input.reconnect ? 'reconnect_required' : 'error',\n lastError: input.message.slice(0, 300),\n nextSyncAtMs: input.reconnect ? null : (input.nextSyncAtMs ?? null),\n ...(input.syncFailures !== undefined ? { syncFailures: input.syncFailures } : {}),\n updatedAtMs: input.nowMs,\n })\n}\n"],"names":["needsReseal","openSecret","sealSecret","pluginConnectionDocId","pluginConnectionRecordDocId","PluginConnectionProviderError","refreshPluginConnectionToken","connectionsCollection","firestore","hostId","collection","doc","connectionRef","listingId","connectionId","connectionRecordRef","tokenContext","owner","purpose","sealTokens","keyring","tokens","previousRefresh","sealedRefreshToken","refreshToken","current","context","sealedAccessToken","accessToken","openToken","record","sealed","opened","token","plaintext","reseal","STATUSES","readConnection","data","choices","key","value","Object","entries","id","label","String","status","includes","accessExpiresAtMs","Number","refreshedAtMs","refreshLeaseUntilMs","scopes","Array","isArray","map","lastSyncAtMs","nextSyncAtMs","lastError","syncFailures","Math","max","trunc","getConnection","snapshot","get","exists","listHostConnections","where","docs","filter","Boolean","connectionReady","declaration","resources","every","resource","toConnectionView","provider","ready","connectedAtMs","newConnectionRecord","input","connection","previous","kind","orgId","nowMs","connectedByUid","uid","updatedAtMs","ACCESS_REFRESH_MARGIN_MS","LEASE_MS","LEASE_WAITS","LEASE_WAIT_MS","PluginConnectionReconnectRequiredError","Error","message","name","accessTokenStale","connectionAccessToken","deps","options","force","now","refreshConnection","ref","sleep","ms","Promise","resolve","setTimeout","startedRefreshedAt","wait","claim","runTransaction","transaction","undefined","update","refreshed","client","error","code","markConnectionSynced","markConnectionError","reconnect","slice"],"mappings":"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"}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import { type SecretBoxKeyring } from '@aglyn/shared-util-tools/secret-box';
|
|
18
|
+
import type { PluginConnectionClient } from './provider-http';
|
|
19
|
+
/**
|
|
20
|
+
* A LISTING'S OAUTH APP, SEALED (AGL-4193).
|
|
21
|
+
*
|
|
22
|
+
* `pluginConnectionCredentials/{listingId}__{connectionId}`: the client id
|
|
23
|
+
* and secret of the PUBLISHER's own OAuth app, which every site's connect for
|
|
24
|
+
* that listing authenticates with. Both are sealed with
|
|
25
|
+
* `PLUGIN_CONNECTIONS_TOKEN_KEY`, each bound to this document and its purpose
|
|
26
|
+
* as the seal's context, so a value copied into another listing's document
|
|
27
|
+
* refuses to open there.
|
|
28
|
+
*
|
|
29
|
+
* Write-only by design: no route reads either value back. A publisher sees
|
|
30
|
+
* "set" or "not set", and replaces the pair whole. `orgId` names the
|
|
31
|
+
* publishing workspace, so that workspace's erasure sweeps the document by
|
|
32
|
+
* field (`orgKeyedCollections`), and the Firestore rules refuse every client.
|
|
33
|
+
*/
|
|
34
|
+
export interface PluginConnectionCredentialsRecord {
|
|
35
|
+
/** The publishing workspace. */
|
|
36
|
+
orgId: string;
|
|
37
|
+
listingId: string;
|
|
38
|
+
connectionId: string;
|
|
39
|
+
sealedClientId: string;
|
|
40
|
+
sealedClientSecret: string;
|
|
41
|
+
updatedAtMs: number;
|
|
42
|
+
updatedBy: string;
|
|
43
|
+
}
|
|
44
|
+
export declare function credentialsRef(firestore: FirebaseFirestore.Firestore, listingId: string, connectionId: string): FirebaseFirestore.DocumentReference;
|
|
45
|
+
/** The seal's context: this document and this value's purpose. */
|
|
46
|
+
export declare const credentialsContext: (listingId: string, connectionId: string, purpose: "client-id" | "client-secret") => string;
|
|
47
|
+
/** Builds the stored record for a pair the publisher typed. */
|
|
48
|
+
export declare function sealCredentials(keyring: SecretBoxKeyring, input: {
|
|
49
|
+
orgId: string;
|
|
50
|
+
listingId: string;
|
|
51
|
+
connectionId: string;
|
|
52
|
+
clientId: string;
|
|
53
|
+
clientSecret: string;
|
|
54
|
+
uid: string;
|
|
55
|
+
nowMs: number;
|
|
56
|
+
}): PluginConnectionCredentialsRecord;
|
|
57
|
+
export declare function saveCredentials(firestore: FirebaseFirestore.Firestore, record: PluginConnectionCredentialsRecord): Promise<void>;
|
|
58
|
+
export declare function clearCredentials(firestore: FirebaseFirestore.Firestore, listingId: string, connectionId: string): Promise<void>;
|
|
59
|
+
/** Whether each named connection's credentials are set, and when: never the values. */
|
|
60
|
+
export declare function credentialsStatus(firestore: FirebaseFirestore.Firestore, listingId: string, connectionIds: readonly string[]): Promise<Record<string, {
|
|
61
|
+
set: boolean;
|
|
62
|
+
updatedAtMs: number | null;
|
|
63
|
+
}>>;
|
|
64
|
+
/**
|
|
65
|
+
* The publisher's OAuth app for a listing's connection, opened, or `null`
|
|
66
|
+
* when it is not set. Throws `SecretBoxError` for a value that does not open
|
|
67
|
+
* (a key that was dropped from the ring, or a tampered document).
|
|
68
|
+
*/
|
|
69
|
+
export declare function openCredentials(firestore: FirebaseFirestore.Firestore, keyring: SecretBoxKeyring, listingId: string, connectionId: string): Promise<PluginConnectionClient | null>;
|
|
70
|
+
/** Every credentials document a workspace's listings hold, for its erasure. */
|
|
71
|
+
export declare function credentialsForOrg(firestore: FirebaseFirestore.Firestore, orgId: string): Promise<FirebaseFirestore.QueryDocumentSnapshot[]>;
|