@aglyn/plugins-marketplace 1.15.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.
Files changed (107) hide show
  1. package/README.md +1 -0
  2. package/package.json +15 -14
  3. package/src/lib/components/editor-connections-provider.component.d.ts +37 -0
  4. package/src/lib/components/editor-connections-provider.component.js +161 -0
  5. package/src/lib/components/editor-connections-provider.component.js.map +1 -0
  6. package/src/lib/components/listing-connection-credentials-card.component.d.ts +33 -0
  7. package/src/lib/components/listing-connection-credentials-card.component.js +318 -0
  8. package/src/lib/components/listing-connection-credentials-card.component.js.map +1 -0
  9. package/src/lib/components/listing-detail-page.component.js +10 -2
  10. package/src/lib/components/listing-detail-page.component.js.map +1 -1
  11. package/src/lib/components/site-plugin-connections-card.component.d.ts +38 -0
  12. package/src/lib/components/site-plugin-connections-card.component.js +576 -0
  13. package/src/lib/components/site-plugin-connections-card.component.js.map +1 -0
  14. package/src/lib/declarations.console-server.d.ts +17 -0
  15. package/src/lib/declarations.console-server.js +41 -0
  16. package/src/lib/declarations.console-server.js.map +1 -0
  17. package/src/lib/declarations.js +11 -2
  18. package/src/lib/declarations.js.map +1 -1
  19. package/src/lib/declarations.server.js +68 -1
  20. package/src/lib/declarations.server.js.map +1 -1
  21. package/src/lib/hooks/use-plugin-connections-api.d.ts +89 -0
  22. package/src/lib/hooks/use-plugin-connections-api.js +132 -0
  23. package/src/lib/hooks/use-plugin-connections-api.js.map +1 -0
  24. package/src/lib/model/editor-connections.d.ts +46 -0
  25. package/src/lib/model/editor-connections.js +70 -0
  26. package/src/lib/model/editor-connections.js.map +1 -0
  27. package/src/lib/model/listing-price-floor.js +7 -4
  28. package/src/lib/model/listing-price-floor.js.map +1 -1
  29. package/src/lib/model/marketplace-publisher-permissions.d.ts +3 -1
  30. package/src/lib/model/marketplace-publisher-permissions.js +14 -0
  31. package/src/lib/model/marketplace-publisher-permissions.js.map +1 -1
  32. package/src/lib/model/marketplace-site-permissions.d.ts +9 -0
  33. package/src/lib/model/marketplace-site-permissions.js +29 -0
  34. package/src/lib/model/marketplace-site-permissions.js.map +1 -1
  35. package/src/lib/model/plugin-connection-card.d.ts +88 -0
  36. package/src/lib/model/plugin-connection-card.js +187 -0
  37. package/src/lib/model/plugin-connection-card.js.map +1 -0
  38. package/src/lib/model/plugin-connections.d.ts +172 -0
  39. package/src/lib/model/plugin-connections.js +119 -0
  40. package/src/lib/model/plugin-connections.js.map +1 -0
  41. package/src/lib/plugin.js +21 -1
  42. package/src/lib/plugin.js.map +1 -1
  43. package/src/lib/server/admin-reviews.js +18 -12
  44. package/src/lib/server/admin-reviews.js.map +1 -1
  45. package/src/lib/server/artifact-inventory.js +3 -1
  46. package/src/lib/server/artifact-inventory.js.map +1 -1
  47. package/src/lib/server/checkout.js +4 -0
  48. package/src/lib/server/checkout.js.map +1 -1
  49. package/src/lib/server/connections/cleanup.d.ts +84 -0
  50. package/src/lib/server/connections/cleanup.js +116 -0
  51. package/src/lib/server/connections/cleanup.js.map +1 -0
  52. package/src/lib/server/connections/config.d.ts +42 -0
  53. package/src/lib/server/connections/config.js +48 -0
  54. package/src/lib/server/connections/config.js.map +1 -0
  55. package/src/lib/server/connections/connection-store.d.ts +173 -0
  56. package/src/lib/server/connections/connection-store.js +331 -0
  57. package/src/lib/server/connections/connection-store.js.map +1 -0
  58. package/src/lib/server/connections/credentials-store.d.ts +71 -0
  59. package/src/lib/server/connections/credentials-store.js +78 -0
  60. package/src/lib/server/connections/credentials-store.js.map +1 -0
  61. package/src/lib/server/connections/erasure.d.ts +25 -0
  62. package/src/lib/server/connections/erasure.js +72 -0
  63. package/src/lib/server/connections/erasure.js.map +1 -0
  64. package/src/lib/server/connections/installed-connection.d.ts +64 -0
  65. package/src/lib/server/connections/installed-connection.js +92 -0
  66. package/src/lib/server/connections/installed-connection.js.map +1 -0
  67. package/src/lib/server/connections/oauth-state.d.ts +129 -0
  68. package/src/lib/server/connections/oauth-state.js +252 -0
  69. package/src/lib/server/connections/oauth-state.js.map +1 -0
  70. package/src/lib/server/connections/provider-http.d.ts +99 -0
  71. package/src/lib/server/connections/provider-http.js +253 -0
  72. package/src/lib/server/connections/provider-http.js.map +1 -0
  73. package/src/lib/server/connections/record-announce.d.ts +20 -0
  74. package/src/lib/server/connections/record-announce.js +53 -0
  75. package/src/lib/server/connections/record-announce.js.map +1 -0
  76. package/src/lib/server/connections/record-mapping.d.ts +63 -0
  77. package/src/lib/server/connections/record-mapping.js +151 -0
  78. package/src/lib/server/connections/record-mapping.js.map +1 -0
  79. package/src/lib/server/connections/record-routes.d.ts +17 -0
  80. package/src/lib/server/connections/record-routes.js +96 -0
  81. package/src/lib/server/connections/record-routes.js.map +1 -0
  82. package/src/lib/server/connections/revoke.d.ts +29 -0
  83. package/src/lib/server/connections/revoke.js +52 -0
  84. package/src/lib/server/connections/revoke.js.map +1 -0
  85. package/src/lib/server/connections/routes.d.ts +77 -0
  86. package/src/lib/server/connections/routes.js +706 -0
  87. package/src/lib/server/connections/routes.js.map +1 -0
  88. package/src/lib/server/connections/site-records-platform.d.ts +24 -0
  89. package/src/lib/server/connections/site-records-platform.js +94 -0
  90. package/src/lib/server/connections/site-records-platform.js.map +1 -0
  91. package/src/lib/server/connections/site-records.d.ts +95 -0
  92. package/src/lib/server/connections/site-records.js +221 -0
  93. package/src/lib/server/connections/site-records.js.map +1 -0
  94. package/src/lib/server/connections/sync.d.ts +110 -0
  95. package/src/lib/server/connections/sync.js +414 -0
  96. package/src/lib/server/connections/sync.js.map +1 -0
  97. package/src/lib/server/install-plugin.js +56 -6
  98. package/src/lib/server/install-plugin.js.map +1 -1
  99. package/src/lib/server/publish-plugin.js +5 -2
  100. package/src/lib/server/publish-plugin.js.map +1 -1
  101. package/src/lib/server/revenue-source.js +6 -5
  102. package/src/lib/server/revenue-source.js.map +1 -1
  103. package/src/lib/server.js +60 -0
  104. package/src/lib/server.js.map +1 -1
  105. package/src/lib/testing/memory-firestore.d.ts +34 -0
  106. package/src/lib/testing/memory-firestore.js +250 -0
  107. 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[]>;