@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.
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
@@ -133,7 +133,7 @@ export const marketplaceArtifactInventory = {
133
133
  return {
134
134
  outcome: 'listed',
135
135
  rows: walk.found.map((one)=>{
136
- var _ref, _ref1, _one_doc_get, _readPluginContributions;
136
+ var _ref, _ref1, _one_doc_get, _readPluginContributions, _one_doc_get1;
137
137
  const owner = walk.owners.get(one.listingId);
138
138
  const declaredNetwork = one.doc.get('manifest.capabilities.network');
139
139
  return {
@@ -150,6 +150,8 @@ export const marketplaceArtifactInventory = {
150
150
  storedVerdict: one.doc.get('verification'),
151
151
  declaredNetwork: Array.isArray(declaredNetwork) ? declaredNetwork.map((origin)=>String(origin)) : [],
152
152
  declaredContributions: (_readPluginContributions = readPluginContributions(one.doc.get('manifest.contributes'))) != null ? _readPluginContributions : null,
153
+ // Declared connections (AGL-4193), as stored for THESE bytes.
154
+ declaredConnections: (_one_doc_get1 = one.doc.get('manifest.connections')) != null ? _one_doc_get1 : null,
153
155
  // The document this row was read from, not one found again by id:
154
156
  // a version's document id and its `version` field are not
155
157
  // guaranteed to be the same string, and writing by id could create
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/artifact-inventory.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 * What the marketplace stores in the artifacts bucket (AGL-3080).\n *\n * The platform's two scheduled sweeps — the orphan reaper and the verdict\n * re-verifier — used to walk `pluginVersions` and `marketplaceListings`\n * themselves, from `/api/admin` routes. Both collections are this plugin's,\n * so the walk is, and the routes ask through\n * `core.plugin-artifact-inventory` instead.\n *\n * ⛔ THE WALK IS EXHAUSTIVE OR IT IS A REFUSAL. The reaper deletes exactly\n * the objects nothing here claims, from a bucket with no object versioning,\n * and a claim this walk never reached is indistinguishable from a claim that\n * does not exist. So a page that throws aborts the whole answer, and passing\n * the caller's `maxScanned` refuses rather than handing back what it managed\n * to see. Paging exists for the query timeout and the memory, never to make\n * the scan optional.\n */\n\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport {\n readPluginContributions,\n type StoredBundleVerdict,\n} from '@aglyn/aglyn/server'\nimport type {\n PluginArtifactClaim,\n PluginArtifactInventorySource,\n PluginArtifactListing,\n PluginArtifactVerdict,\n PluginArtifactVersion,\n} from '@aglyn/aglyn/plugin-manager/plugin-artifact-inventory'\n\n/** Version documents read per query while walking the collection group. */\nconst SCAN_PAGE = 500\n\n/** Where a reviewer opens one stored version — this plugin's staff page. */\nfunction reviewLinkFor(listingId: string, version: string): string {\n return `/admin/plugin-reviews/${listingId}?version=${encodeURIComponent(version)}`\n}\n\n/** What one walk collects per document before the owners are read. */\ninterface ScannedVersion {\n doc: FirebaseFirestore.QueryDocumentSnapshot\n listingId: string\n version: string\n sha256: string\n}\n\ninterface OwnerFacts {\n name: string\n reviewStatus: string\n}\n\n/**\n * One exhaustive walk of every stored version, shared by both listings.\n *\n * The `select` is the only difference between them — see the contract's note\n * on why the reaper leaves the manifests on the server — so the paging, the\n * ceiling and the refusal wording are written once. Two copies of a walk\n * whose failure mode is a permanent delete is one copy too many.\n */\nasync function walkVersions(\n maxScanned: number,\n fields: readonly string[] | null,\n): Promise<\n | {\n outcome: 'listed'\n found: ScannedVersion[]\n owners: Map<string, OwnerFacts>\n scanned: number\n }\n | { outcome: 'refused'; reason: string; scanned: number }\n> {\n const firestore = firebaseAdmin.app().firestore()\n const found: ScannedVersion[] = []\n const listingIds = new Set<string>()\n let scanned = 0\n let cursor: FirebaseFirestore.QueryDocumentSnapshot | null = null\n for (;;) {\n let query = firestore\n .collectionGroup('pluginVersions')\n .orderBy('__name__') as FirebaseFirestore.Query\n if (fields) query = query.select(...fields)\n query = query.limit(SCAN_PAGE)\n const page = await (cursor ? query.startAfter(cursor) : query).get()\n if (page.empty) break\n for (const doc of page.docs) {\n scanned += 1\n const listingId = doc.ref.parent.parent?.id\n const sha256 = doc.get('sha256')\n if (!listingId || typeof sha256 !== 'string' || !sha256) continue\n found.push({\n doc,\n listingId,\n version: String(doc.get('version') ?? doc.id),\n sha256,\n })\n listingIds.add(listingId)\n }\n if (scanned > maxScanned) {\n return {\n outcome: 'refused',\n reason:\n `Refusing to answer: more than ${maxScanned} stored plugin ` +\n 'version claims. A claim set this run could not finish reading ' +\n 'cannot be acted on.',\n scanned,\n }\n }\n cursor = page.docs[page.docs.length - 1] ?? null\n if (page.docs.length < SCAN_PAGE) break\n }\n /*\n * Which of the claiming listings still exist, and what they are called.\n * Read once per listing rather than once per version: a listing with forty\n * versions is one document either way, and the reaper needs only the\n * existence while the re-verifier needs the name for its report.\n */\n const owners = new Map<string, OwnerFacts>()\n const refs = [...listingIds].map((id) =>\n firestore.collection('marketplaceListings').doc(id),\n )\n if (refs.length) {\n const snapshots = await firestore.getAll(...refs)\n for (const snapshot of snapshots) {\n if (!snapshot.exists) continue\n owners.set(snapshot.id, {\n name: String(snapshot.get('displayName') ?? snapshot.id),\n reviewStatus: String(snapshot.get('reviewStatus') ?? 'unknown'),\n })\n }\n }\n return { outcome: 'listed', found, owners, scanned }\n}\n\nexport const marketplaceArtifactInventory: PluginArtifactInventorySource = {\n async listClaims(options): Promise<PluginArtifactListing<PluginArtifactClaim>> {\n // The two path segments and the hash are all the reaper's join reads.\n const walk = await walkVersions(options.maxScanned, ['sha256', 'version'])\n if (walk.outcome === 'refused') return walk\n return {\n outcome: 'listed',\n rows: walk.found.map((one) => ({\n listingId: one.listingId,\n version: one.version,\n sha256: one.sha256,\n ownerLive: walk.owners.has(one.listingId),\n })),\n scanned: walk.scanned,\n }\n },\n\n async listVersions(\n options,\n ): Promise<PluginArtifactListing<PluginArtifactVersion>> {\n const walk = await walkVersions(options.maxScanned, null)\n if (walk.outcome === 'refused') return walk\n return {\n outcome: 'listed',\n rows: walk.found.map((one) => {\n const owner = walk.owners.get(one.listingId)\n const declaredNetwork = one.doc.get('manifest.capabilities.network')\n return {\n listingId: one.listingId,\n version: one.version,\n sha256: one.sha256,\n ownerLive: Boolean(owner),\n // The id, never a blank: a report row naming nothing would send a\n // reviewer looking for a listing called \"\".\n ownerName: owner?.name ?? one.listingId,\n reviewStatus: owner?.reviewStatus ?? 'unknown',\n activeInstalls: Number(one.doc.get('activeInstalls') ?? 0),\n reviewLink: reviewLinkFor(one.listingId, one.version),\n storedVerdict: one.doc.get('verification') as\n | StoredBundleVerdict\n | undefined,\n declaredNetwork: Array.isArray(declaredNetwork)\n ? declaredNetwork.map((origin: unknown) => String(origin))\n : [],\n declaredContributions:\n readPluginContributions(one.doc.get('manifest.contributes')) ??\n null,\n // The document this row was read from, not one found again by id:\n // a version's document id and its `version` field are not\n // guaranteed to be the same string, and writing by id could create\n // a sibling document holding a verdict nothing reads.\n record: (verdict: PluginArtifactVerdict) =>\n one.doc.ref\n .set(\n {\n verification: {\n ...verdict,\n checkedAt:\n firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n },\n },\n { merge: true },\n )\n .then(() => undefined),\n }\n }),\n scanned: walk.scanned,\n }\n },\n}\n"],"names":["firebaseAdmin","readPluginContributions","SCAN_PAGE","reviewLinkFor","listingId","version","encodeURIComponent","walkVersions","maxScanned","fields","firestore","app","found","listingIds","Set","scanned","cursor","page","query","collectionGroup","orderBy","select","limit","startAfter","get","empty","doc","docs","ref","parent","id","sha256","push","String","add","outcome","reason","length","owners","Map","refs","map","collection","snapshots","getAll","snapshot","exists","set","name","reviewStatus","marketplaceArtifactInventory","listClaims","options","walk","rows","one","ownerLive","has","listVersions","owner","declaredNetwork","Boolean","ownerName","activeInstalls","Number","reviewLink","storedVerdict","Array","isArray","origin","declaredContributions","record","verdict","verification","checkedAt","FieldValue","serverTimestamp","merge","then","undefined"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/artifact-inventory.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 * What the marketplace stores in the artifacts bucket (AGL-3080).\n *\n * The platform's two scheduled sweeps — the orphan reaper and the verdict\n * re-verifier — used to walk `pluginVersions` and `marketplaceListings`\n * themselves, from `/api/admin` routes. Both collections are this plugin's,\n * so the walk is, and the routes ask through\n * `core.plugin-artifact-inventory` instead.\n *\n * ⛔ THE WALK IS EXHAUSTIVE OR IT IS A REFUSAL. The reaper deletes exactly\n * the objects nothing here claims, from a bucket with no object versioning,\n * and a claim this walk never reached is indistinguishable from a claim that\n * does not exist. So a page that throws aborts the whole answer, and passing\n * the caller's `maxScanned` refuses rather than handing back what it managed\n * to see. Paging exists for the query timeout and the memory, never to make\n * the scan optional.\n */\n\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport {\n readPluginContributions,\n type StoredBundleVerdict,\n} from '@aglyn/aglyn/server'\nimport type {\n PluginArtifactClaim,\n PluginArtifactInventorySource,\n PluginArtifactListing,\n PluginArtifactVerdict,\n PluginArtifactVersion,\n} from '@aglyn/aglyn/plugin-manager/plugin-artifact-inventory'\n\n/** Version documents read per query while walking the collection group. */\nconst SCAN_PAGE = 500\n\n/** Where a reviewer opens one stored version — this plugin's staff page. */\nfunction reviewLinkFor(listingId: string, version: string): string {\n return `/admin/plugin-reviews/${listingId}?version=${encodeURIComponent(version)}`\n}\n\n/** What one walk collects per document before the owners are read. */\ninterface ScannedVersion {\n doc: FirebaseFirestore.QueryDocumentSnapshot\n listingId: string\n version: string\n sha256: string\n}\n\ninterface OwnerFacts {\n name: string\n reviewStatus: string\n}\n\n/**\n * One exhaustive walk of every stored version, shared by both listings.\n *\n * The `select` is the only difference between them — see the contract's note\n * on why the reaper leaves the manifests on the server — so the paging, the\n * ceiling and the refusal wording are written once. Two copies of a walk\n * whose failure mode is a permanent delete is one copy too many.\n */\nasync function walkVersions(\n maxScanned: number,\n fields: readonly string[] | null,\n): Promise<\n | {\n outcome: 'listed'\n found: ScannedVersion[]\n owners: Map<string, OwnerFacts>\n scanned: number\n }\n | { outcome: 'refused'; reason: string; scanned: number }\n> {\n const firestore = firebaseAdmin.app().firestore()\n const found: ScannedVersion[] = []\n const listingIds = new Set<string>()\n let scanned = 0\n let cursor: FirebaseFirestore.QueryDocumentSnapshot | null = null\n for (;;) {\n let query = firestore\n .collectionGroup('pluginVersions')\n .orderBy('__name__') as FirebaseFirestore.Query\n if (fields) query = query.select(...fields)\n query = query.limit(SCAN_PAGE)\n const page = await (cursor ? query.startAfter(cursor) : query).get()\n if (page.empty) break\n for (const doc of page.docs) {\n scanned += 1\n const listingId = doc.ref.parent.parent?.id\n const sha256 = doc.get('sha256')\n if (!listingId || typeof sha256 !== 'string' || !sha256) continue\n found.push({\n doc,\n listingId,\n version: String(doc.get('version') ?? doc.id),\n sha256,\n })\n listingIds.add(listingId)\n }\n if (scanned > maxScanned) {\n return {\n outcome: 'refused',\n reason:\n `Refusing to answer: more than ${maxScanned} stored plugin ` +\n 'version claims. A claim set this run could not finish reading ' +\n 'cannot be acted on.',\n scanned,\n }\n }\n cursor = page.docs[page.docs.length - 1] ?? null\n if (page.docs.length < SCAN_PAGE) break\n }\n /*\n * Which of the claiming listings still exist, and what they are called.\n * Read once per listing rather than once per version: a listing with forty\n * versions is one document either way, and the reaper needs only the\n * existence while the re-verifier needs the name for its report.\n */\n const owners = new Map<string, OwnerFacts>()\n const refs = [...listingIds].map((id) =>\n firestore.collection('marketplaceListings').doc(id),\n )\n if (refs.length) {\n const snapshots = await firestore.getAll(...refs)\n for (const snapshot of snapshots) {\n if (!snapshot.exists) continue\n owners.set(snapshot.id, {\n name: String(snapshot.get('displayName') ?? snapshot.id),\n reviewStatus: String(snapshot.get('reviewStatus') ?? 'unknown'),\n })\n }\n }\n return { outcome: 'listed', found, owners, scanned }\n}\n\nexport const marketplaceArtifactInventory: PluginArtifactInventorySource = {\n async listClaims(options): Promise<PluginArtifactListing<PluginArtifactClaim>> {\n // The two path segments and the hash are all the reaper's join reads.\n const walk = await walkVersions(options.maxScanned, ['sha256', 'version'])\n if (walk.outcome === 'refused') return walk\n return {\n outcome: 'listed',\n rows: walk.found.map((one) => ({\n listingId: one.listingId,\n version: one.version,\n sha256: one.sha256,\n ownerLive: walk.owners.has(one.listingId),\n })),\n scanned: walk.scanned,\n }\n },\n\n async listVersions(\n options,\n ): Promise<PluginArtifactListing<PluginArtifactVersion>> {\n const walk = await walkVersions(options.maxScanned, null)\n if (walk.outcome === 'refused') return walk\n return {\n outcome: 'listed',\n rows: walk.found.map((one) => {\n const owner = walk.owners.get(one.listingId)\n const declaredNetwork = one.doc.get('manifest.capabilities.network')\n return {\n listingId: one.listingId,\n version: one.version,\n sha256: one.sha256,\n ownerLive: Boolean(owner),\n // The id, never a blank: a report row naming nothing would send a\n // reviewer looking for a listing called \"\".\n ownerName: owner?.name ?? one.listingId,\n reviewStatus: owner?.reviewStatus ?? 'unknown',\n activeInstalls: Number(one.doc.get('activeInstalls') ?? 0),\n reviewLink: reviewLinkFor(one.listingId, one.version),\n storedVerdict: one.doc.get('verification') as\n | StoredBundleVerdict\n | undefined,\n declaredNetwork: Array.isArray(declaredNetwork)\n ? declaredNetwork.map((origin: unknown) => String(origin))\n : [],\n declaredContributions:\n readPluginContributions(one.doc.get('manifest.contributes')) ??\n null,\n // Declared connections (AGL-4193), as stored for THESE bytes.\n declaredConnections: one.doc.get('manifest.connections') ?? null,\n // The document this row was read from, not one found again by id:\n // a version's document id and its `version` field are not\n // guaranteed to be the same string, and writing by id could create\n // a sibling document holding a verdict nothing reads.\n record: (verdict: PluginArtifactVerdict) =>\n one.doc.ref\n .set(\n {\n verification: {\n ...verdict,\n checkedAt:\n firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n },\n },\n { merge: true },\n )\n .then(() => undefined),\n }\n }),\n scanned: walk.scanned,\n }\n },\n}\n"],"names":["firebaseAdmin","readPluginContributions","SCAN_PAGE","reviewLinkFor","listingId","version","encodeURIComponent","walkVersions","maxScanned","fields","firestore","app","found","listingIds","Set","scanned","cursor","page","query","collectionGroup","orderBy","select","limit","startAfter","get","empty","doc","docs","ref","parent","id","sha256","push","String","add","outcome","reason","length","owners","Map","refs","map","collection","snapshots","getAll","snapshot","exists","set","name","reviewStatus","marketplaceArtifactInventory","listClaims","options","walk","rows","one","ownerLive","has","listVersions","owner","declaredNetwork","Boolean","ownerName","activeInstalls","Number","reviewLink","storedVerdict","Array","isArray","origin","declaredContributions","declaredConnections","record","verdict","verification","checkedAt","FieldValue","serverTimestamp","merge","then","undefined"],"mappings":"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"}
@@ -18,6 +18,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
18
18
  import { checkoutSessionCardAuthenticationParams } from "@aglyn/tenant-data-admin/server/stripe-card-authentication";
19
19
  import { buildRoute, claimAttempt, Route } from "@aglyn/aglyn/server";
20
20
  import { siteCreateReturnQuery } from "@aglyn/aglyn/app-utils/site-create-return";
21
+ import { excludeBuyNowPayLater } from "@aglyn/aglyn/app-utils/buy-now-pay-later";
21
22
  import { resolveOrgPermissions } from "@aglyn/tenant-runtime/org-permissions";
22
23
  import { readStarterCheckoutReturn } from "../model/starter-query.js";
23
24
  import { MARKETPLACE_TAX_CODE, resolveSale } from "./sale.js";
@@ -234,6 +235,9 @@ export { MARKETPLACE_TAX_CODE };
234
235
  } : {}, decoded.email ? {
235
236
  customer_email: decoded.email
236
237
  } : {}));
238
+ // No buy-now-pay-later on a listing either (AGL-4198): a card or a
239
+ // card-rate wallet, which the listing floor more than covers.
240
+ excludeBuyNowPayLater(params);
237
241
  // Point of no return (AGL-1697). The purchase check above closes the
238
242
  // SEQUENTIAL duplicate — a second buy after the webhook has written the
239
243
  // first purchase — and cannot close the concurrent one, because between two
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/checkout.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\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { checkoutSessionCardAuthenticationParams } from '@aglyn/tenant-data-admin/server/stripe-card-authentication'\nimport {\n buildRoute,\n claimAttempt,\n Route,\n type AttemptClaim,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport { siteCreateReturnQuery } from '@aglyn/aglyn/app-utils/site-create-return'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport { readStarterCheckoutReturn } from '../model/starter-query'\nimport { MARKETPLACE_TAX_CODE, resolveSale } from './sale'\n\nexport { MARKETPLACE_TAX_CODE }\n\n/**\n * Checkout for a paid marketplace listing (AGL-46): one-time destination\n * charge to the publisher's Express account. The platform's cut comes from\n * the seller org's ENTITLEMENTS (AGL-1543: 20%, 30% effective-free), sales\n * tax is collected platform-side per the marketplace-provider registration\n * (AGL-1544), and the seller receives a fixed transfer of their pre-tax\n * share. The webhook writes the purchase record on\n * `checkout.session.completed`; install stays gated on that record. 501\n * without Stripe env.\n */\nexport const checkoutHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n if (!process.env.STRIPE_SECRET_KEY) {\n return res\n .status(501)\n .json({ error: 'Purchases are not configured (STRIPE_SECRET_KEY).' })\n }\n const listingId = String(req.body?.listingId ?? '')\n const hostId = String(req.body?.hostId ?? '')\n // The org this licence is being bought FOR (AGL-2331). Requested, never\n // trusted: it is resolved through the caller's own membership below, exactly\n // as `connect.ts` resolves the org a payout account is being set up for.\n const requestedOrgId = String(req.body?.orgId ?? '')\n // One purchase attempt, minted by the browser (AGL-1697). Node lowercases\n // incoming headers, but read both spellings — the plugin API request type\n // makes no promise about casing.\n const idempotencyKey = String(\n req.headers['idempotency-key'] ?? req.headers['Idempotency-Key'] ?? '',\n )\n .trim()\n .slice(0, 200)\n if (!listingId) return res.status(400).json({ error: 'Missing listingId' })\n // Where the buyer comes back to (AGL-4049): the marketplace, or Create\n // site reopened on the starter they picked there. A name, never an\n // address the browser hands over.\n const returnTo = readStarterCheckoutReturn(req.body?.return)\n if (returnTo !== null && typeof returnTo === 'object') {\n return res.status(400).json({ error: returnTo.error })\n }\n\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n\n let claim: AttemptClaim | null = null\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n // WHICH ORGANIZATION IS BUYING (AGL-2331).\n //\n // A marketplace purchase licenses an organization, not a person — see\n // `purchaseEntitlesOrg` in the model for why, and for what the published\n // Publisher Agreement §8.1 already says about it. So a Checkout session\n // that cannot name the org it licenses must not open: the purchase\n // document it produces would carry no `buyerOrgId`, and a half-populated\n // org field is a worse premise for an entitlement than an absent one —\n // the gate would have to guess whether a missing value means \"legacy,\n // grandfathered\" or \"modern, but nobody wrote it\".\n //\n // Resolved through `resolveOrgPermissions`, not from the body and not from\n // `hostIndex`. The old derivation was `hostId` → `hostIndex.orgId` with no\n // membership test at all, which is a real hole once the field grants\n // anything: post any host id and the purchase would be stamped with an org\n // the buyer has no standing in, entitling strangers. This is the same\n // resolution the seven install routes and `connect.ts` use, so the org\n // that gets the licence is by construction an org the buyer could install\n // into.\n //\n // `installPlugins` rather than a bare membership: buying is the act of\n // acquiring something to install, and a viewer who cannot install must not\n // be able to spend the workspace's money on one. It is also what keeps\n // this door and the install door agreeing about who the licence is for.\n const membership = await resolveOrgPermissions(decoded.uid, {\n orgId: requestedOrgId || null,\n hostId: requestedOrgId ? null : hostId || null,\n })\n const buyerOrgId = membership.orgId ?? ''\n if (!buyerOrgId) {\n return res.status(400).json({\n error:\n 'A purchase is licensed to an organization — open the marketplace ' +\n 'from the workspace you are buying for.',\n })\n }\n if (requestedOrgId && buyerOrgId !== requestedOrgId) {\n return res.status(403).json({ error: 'Not a member of that organization' })\n }\n const firestore = firebaseAdmin.app().firestore()\n // Everything a sale decides before Stripe (AGL-4049), shared with the\n // in-app payment so the two doors cannot disagree about what is on sale.\n const resolved = await resolveSale({\n firestore,\n listingId,\n buyer: { uid: decoded.uid, orgId: buyerOrgId, permissions: membership.permissions },\n })\n if ('refusal' in resolved) {\n return res.status(resolved.refusal.status).json(resolved.refusal.body)\n }\n const {\n listing,\n siteStarter,\n sellerOrgId,\n accountId,\n amountCents,\n feeCents,\n transferCents,\n platformSale,\n } = resolved.sale\n\n const origin = req.headers.origin ?? `https://${req.headers.host}`\n // Stripe returns the BUYER to the org marketplace (AGL-775) — the\n // per-site marketplace page it used to land on was retired. Only the\n // buyer's org slug is needed now (`sellerOrgId` is the wrong org), and\n // it's resolved from the host here since server code cannot call\n // useConsoleHostRoute. `/manage/marketplace` is the hostless fallback.\n //\n // The slug comes off the VALIDATED buyer org resolved at the top of the\n // handler now (AGL-2331) rather than a second, unvalidated `hostIndex`\n // read: one org id decides where the buyer lands, which org the licence\n // entitles, and which org's erasure sweeps the idempotency claim, so\n // having two derivations of it was a way for those three to disagree.\n const buyerOrgSlug = (\n await firestore.collection('orgs').doc(buyerOrgId).get()\n ).get('slug') as string | undefined\n // A site starter bought from Create site goes back to it (AGL-4049):\n // the workspace's sites page, which reopens Create site on the same\n // pick. Anything else, or a workspace with no slug to build that page\n // from, goes to the marketplace as before.\n const backToCreate =\n returnTo === 'create-site' && siteStarter && buyerOrgSlug\n ? siteCreateReturnQuery(listingId)\n : null\n const returnPath = backToCreate\n ? buildRoute(Route.HOST_LIST, { orgSlug: buyerOrgSlug as string })\n : buyerOrgSlug\n ? buildRoute(Route.ORG_MARKETPLACE, { orgSlug: buyerOrgSlug })\n : Route.ORG_MARKETPLACE\n // Each way back is built as a query, so `purchase` joins whatever the\n // page already asks for rather than being glued on as a second `?`.\n const returnUrl = (purchase: 'success' | 'canceled'): string => {\n const query = new URLSearchParams(backToCreate ?? undefined)\n query.set('purchase', purchase)\n return `${origin}${returnPath}?${query.toString()}`\n }\n // Tax + funds flow (AGL-1544). Aglyn registered as a marketplace\n // provider with the Texas Comptroller: it collects and remits sales tax\n // on sellers' behalf. That is the PLATFORM's charge — no `on_behalf_of`,\n // `automatic_tax` on the platform session, tax added ON TOP of the\n // listing price (`tax_behavior: exclusive`) with a full billing address\n // to compute from. The transfer is a FIXED amount (the seller's share of\n // the pre-tax price) rather than `application_fee_amount`, because the\n // fee form makes Stripe transfer `amount_total − fee` — and amount_total\n // includes the tax, which must stay with the platform that owes it.\n const params = new URLSearchParams({\n mode: 'payment',\n // 3-D Secure, from the one seam every card payment shares (AGL-3360).\n ...checkoutSessionCardAuthenticationParams('payment'),\n 'line_items[0][quantity]': '1',\n 'line_items[0][price_data][currency]': 'usd',\n 'line_items[0][price_data][unit_amount]': String(amountCents),\n 'line_items[0][price_data][tax_behavior]': 'exclusive',\n 'line_items[0][price_data][product_data][name]': String(\n listing.displayName ?? 'Marketplace component',\n ).slice(0, 120),\n // The classification, stated rather than inherited (AGL-1553). See\n // MARKETPLACE_TAX_CODE above: same value the account preset carries, so\n // the tax computed is unchanged, but it no longer depends on a Dashboard\n // field owned by Aglyn's subscription products.\n 'line_items[0][price_data][product_data][tax_code]': MARKETPLACE_TAX_CODE,\n 'automatic_tax[enabled]': 'true',\n billing_address_collection: 'required',\n // Nobody to pay when the platform's own org is the seller (AGL-4049):\n // the sale stays on the platform's account, tax and all.\n ...(platformSale\n ? {}\n : {\n 'payment_intent_data[transfer_data][destination]': String(accountId),\n 'payment_intent_data[transfer_data][amount]': String(transferCents),\n }),\n // THE DISCRIMINATOR THE REFUND DOOR HAS NO OTHER WAY TO GET (AGL-2148).\n //\n // `metadata[type]` below rides on the SESSION, and `charge.refunded`\n // never sees a session — its object is the charge. The platform\n // endpoint receives `charge.refunded` for storefront orders and\n // subscription charges too, so the webhook cannot tell a marketplace\n // refund from any other one except by joining on the payment intent,\n // which is exactly the join that comes up empty during the window this\n // metadata exists to close (the purchase document is written by\n // `checkout.session.completed`, and that delivery retries).\n //\n // Stripe copies a PaymentIntent's metadata onto the charge it creates,\n // so stamping it here makes `charge.metadata.type` readable straight\n // off the refund event — no Firestore join, no Stripe round trip, and\n // no orphan record written for the commerce and subscription refunds\n // that share this endpoint.\n //\n // `listingId`/`buyerUid` ride along for forensics only: an orphan that\n // never drains is a support case, and the payment intent alone does not\n // say whose purchase of what it was.\n 'payment_intent_data[metadata][type]': 'marketplace-purchase',\n 'payment_intent_data[metadata][listingId]': listingId,\n 'payment_intent_data[metadata][buyerUid]': decoded.uid,\n 'payment_intent_data[metadata][buyerOrgId]': buyerOrgId,\n success_url: returnUrl('success'),\n cancel_url: returnUrl('canceled'),\n client_reference_id: decoded.uid,\n 'metadata[type]': 'marketplace-purchase',\n 'metadata[listingId]': listingId,\n 'metadata[buyerUid]': decoded.uid,\n // THE ORG THE LICENCE IS FOR (AGL-2331). The webhook writes the\n // purchase document from this metadata and had no buyer org to write,\n // which is why the entitlement could only ever be person-scoped: the\n // one field the gate needs was never carried across the Stripe\n // boundary. Validated above against the caller's own membership, so a\n // session can never license an org the buyer has no standing in.\n 'metadata[buyerOrgId]': buyerOrgId,\n 'metadata[sellerOrgId]': sellerOrgId,\n 'metadata[feeCents]': String(feeCents),\n 'metadata[transferCents]': String(transferCents),\n // The buyer's GA client id (AGL-1638), so the server-side `purchase`\n // the webhook sends joins the browser session that produced it. The\n // webhook has always READ `metadata.ga_client_id`; nothing wrote it,\n // so the read was dead and every marketplace sale fell through to a\n // synthesized, sessionless GA user — making plugin revenue permanently\n // unattributable to any channel.\n //\n // Carried on the SESSION rather than a subscription because this is a\n // one-time payment; `checkout.session.completed` is where it is read.\n //\n // Validated to GA's `<digits>.<digits>` shape and dropped otherwise:\n // the value is client-supplied and ends up in Stripe metadata, so it is\n // not accepted on trust. Same guard /api/billing/checkout applies.\n ...(/^\\d+\\.\\d+$/.test(String(req.body?.gaClientId ?? ''))\n ? { 'metadata[ga_client_id]': String(req.body.gaClientId) }\n : {}),\n ...(decoded.email ? { customer_email: decoded.email } : {}),\n })\n // Point of no return (AGL-1697). The purchase check above closes the\n // SEQUENTIAL duplicate — a second buy after the webhook has written the\n // first purchase — and cannot close the concurrent one, because between two\n // clicks a second apart there is no purchase record yet to find. The claim\n // is what covers that window: `create()` on a document Firestore refuses to\n // create twice.\n //\n // Scoped to the listing AND the buyer, so two people buying the same\n // component at the same moment never contend, and one person's two tabs\n // always do.\n //\n // The claim is taken here rather than at the top so that every deterministic\n // refusal above it — unknown listing, free listing, self-purchase, already\n // owned, publisher not paid out, seller cannot sell — answers without\n // burning the key. Those are all states a buyer or publisher fixes and then\n // presses the same button again.\n const claimed = await claimAttempt(firestore, {\n kind: 'marketplace-checkout',\n // Scoped to the ORG as well as the buyer since AGL-2331. The scope is\n // what decides which two attempts are allowed to contend, and a\n // licence for one workspace and a licence for another are now two\n // different goods — an agency owner buying for two clients from two\n // tabs must not have one of them read as a double-click of the other.\n scopeId: `${listingId}:${buyerOrgId}:${decoded.uid}`,\n // The BUYER's org, so `eraseOrgIdempotencyKeys` sweeps this with the\n // buyer's data (AGL-1448) — the seller keeps the sale.\n orgId: buyerOrgId,\n key: idempotencyKey,\n busyMessage: 'This purchase is already being processed',\n })\n if ('replay' in claimed) {\n return res.status(claimed.replay.status).json(claimed.replay.body)\n }\n claim = claimed.claim\n\n const response = await fetch(\n 'https://api.stripe.com/v1/checkout/sessions',\n {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,\n 'Content-Type': 'application/x-www-form-urlencoded',\n // The half that costs real money. Stripe replays the existing\n // session for a repeated key instead of opening a second one, which\n // covers the window where our own claim is written but the response\n // never arrives — the failure this route was most exposed to, since\n // it writes nothing of its own to reconcile against.\n ...(claim.stripeKey ? { 'Idempotency-Key': claim.stripeKey } : {}),\n },\n body: params.toString(),\n },\n )\n const session = (await response.json()) as { url?: string; error?: any }\n if (!response.ok || !session.url) {\n console.error('Stripe checkout error', session.error)\n // The purchase did not start — let the same key be tried again rather\n // than locking this buyer out of this listing over one flaky moment.\n await claim.release()\n return res.status(502).json({ error: 'Stripe checkout failed' })\n }\n const payload = { url: session.url }\n // A repeat of this attempt now replays the SAME session url. Better than\n // refusing it: the buyer lands back on the checkout they already had open.\n await claim.record(200, payload)\n return res.status(200).json(payload)\n } catch (error) {\n console.error(error)\n // Release on the way out so a transient failure does not strand the key\n // and lock the buyer out of this listing until they reload.\n await claim?.release()\n return res.status(500).json({ error: 'Checkout failed' })\n }\n}\n"],"names":["firebaseAdmin","checkoutSessionCardAuthenticationParams","buildRoute","claimAttempt","Route","siteCreateReturnQuery","resolveOrgPermissions","readStarterCheckoutReturn","MARKETPLACE_TAX_CODE","resolveSale","checkoutHandler","req","res","method","status","json","error","process","env","STRIPE_SECRET_KEY","listingId","String","body","hostId","requestedOrgId","orgId","idempotencyKey","headers","trim","slice","returnTo","return","authorization","idToken","startsWith","length","undefined","claim","membership","listing","decoded","app","auth","verifyIdToken","uid","buyerOrgId","firestore","resolved","buyer","permissions","refusal","siteStarter","sellerOrgId","accountId","amountCents","feeCents","transferCents","platformSale","sale","origin","host","buyerOrgSlug","collection","doc","get","backToCreate","returnPath","HOST_LIST","orgSlug","ORG_MARKETPLACE","returnUrl","purchase","query","URLSearchParams","set","toString","params","mode","displayName","billing_address_collection","success_url","cancel_url","client_reference_id","test","gaClientId","email","customer_email","claimed","kind","scopeId","key","busyMessage","replay","response","fetch","Authorization","stripeKey","session","ok","url","console","release","payload","record"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/checkout.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\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { checkoutSessionCardAuthenticationParams } from '@aglyn/tenant-data-admin/server/stripe-card-authentication'\nimport {\n buildRoute,\n claimAttempt,\n Route,\n type AttemptClaim,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport { siteCreateReturnQuery } from '@aglyn/aglyn/app-utils/site-create-return'\nimport { excludeBuyNowPayLater } from '@aglyn/aglyn/app-utils/buy-now-pay-later'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport { readStarterCheckoutReturn } from '../model/starter-query'\nimport { MARKETPLACE_TAX_CODE, resolveSale } from './sale'\n\nexport { MARKETPLACE_TAX_CODE }\n\n/**\n * Checkout for a paid marketplace listing (AGL-46): one-time destination\n * charge to the publisher's Express account. The platform's cut comes from\n * the seller org's ENTITLEMENTS (AGL-1543: 20%, 30% effective-free), sales\n * tax is collected platform-side per the marketplace-provider registration\n * (AGL-1544), and the seller receives a fixed transfer of their pre-tax\n * share. The webhook writes the purchase record on\n * `checkout.session.completed`; install stays gated on that record. 501\n * without Stripe env.\n */\nexport const checkoutHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n if (!process.env.STRIPE_SECRET_KEY) {\n return res\n .status(501)\n .json({ error: 'Purchases are not configured (STRIPE_SECRET_KEY).' })\n }\n const listingId = String(req.body?.listingId ?? '')\n const hostId = String(req.body?.hostId ?? '')\n // The org this licence is being bought FOR (AGL-2331). Requested, never\n // trusted: it is resolved through the caller's own membership below, exactly\n // as `connect.ts` resolves the org a payout account is being set up for.\n const requestedOrgId = String(req.body?.orgId ?? '')\n // One purchase attempt, minted by the browser (AGL-1697). Node lowercases\n // incoming headers, but read both spellings — the plugin API request type\n // makes no promise about casing.\n const idempotencyKey = String(\n req.headers['idempotency-key'] ?? req.headers['Idempotency-Key'] ?? '',\n )\n .trim()\n .slice(0, 200)\n if (!listingId) return res.status(400).json({ error: 'Missing listingId' })\n // Where the buyer comes back to (AGL-4049): the marketplace, or Create\n // site reopened on the starter they picked there. A name, never an\n // address the browser hands over.\n const returnTo = readStarterCheckoutReturn(req.body?.return)\n if (returnTo !== null && typeof returnTo === 'object') {\n return res.status(400).json({ error: returnTo.error })\n }\n\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n\n let claim: AttemptClaim | null = null\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n // WHICH ORGANIZATION IS BUYING (AGL-2331).\n //\n // A marketplace purchase licenses an organization, not a person — see\n // `purchaseEntitlesOrg` in the model for why, and for what the published\n // Publisher Agreement §8.1 already says about it. So a Checkout session\n // that cannot name the org it licenses must not open: the purchase\n // document it produces would carry no `buyerOrgId`, and a half-populated\n // org field is a worse premise for an entitlement than an absent one —\n // the gate would have to guess whether a missing value means \"legacy,\n // grandfathered\" or \"modern, but nobody wrote it\".\n //\n // Resolved through `resolveOrgPermissions`, not from the body and not from\n // `hostIndex`. The old derivation was `hostId` → `hostIndex.orgId` with no\n // membership test at all, which is a real hole once the field grants\n // anything: post any host id and the purchase would be stamped with an org\n // the buyer has no standing in, entitling strangers. This is the same\n // resolution the seven install routes and `connect.ts` use, so the org\n // that gets the licence is by construction an org the buyer could install\n // into.\n //\n // `installPlugins` rather than a bare membership: buying is the act of\n // acquiring something to install, and a viewer who cannot install must not\n // be able to spend the workspace's money on one. It is also what keeps\n // this door and the install door agreeing about who the licence is for.\n const membership = await resolveOrgPermissions(decoded.uid, {\n orgId: requestedOrgId || null,\n hostId: requestedOrgId ? null : hostId || null,\n })\n const buyerOrgId = membership.orgId ?? ''\n if (!buyerOrgId) {\n return res.status(400).json({\n error:\n 'A purchase is licensed to an organization — open the marketplace ' +\n 'from the workspace you are buying for.',\n })\n }\n if (requestedOrgId && buyerOrgId !== requestedOrgId) {\n return res.status(403).json({ error: 'Not a member of that organization' })\n }\n const firestore = firebaseAdmin.app().firestore()\n // Everything a sale decides before Stripe (AGL-4049), shared with the\n // in-app payment so the two doors cannot disagree about what is on sale.\n const resolved = await resolveSale({\n firestore,\n listingId,\n buyer: { uid: decoded.uid, orgId: buyerOrgId, permissions: membership.permissions },\n })\n if ('refusal' in resolved) {\n return res.status(resolved.refusal.status).json(resolved.refusal.body)\n }\n const {\n listing,\n siteStarter,\n sellerOrgId,\n accountId,\n amountCents,\n feeCents,\n transferCents,\n platformSale,\n } = resolved.sale\n\n const origin = req.headers.origin ?? `https://${req.headers.host}`\n // Stripe returns the BUYER to the org marketplace (AGL-775) — the\n // per-site marketplace page it used to land on was retired. Only the\n // buyer's org slug is needed now (`sellerOrgId` is the wrong org), and\n // it's resolved from the host here since server code cannot call\n // useConsoleHostRoute. `/manage/marketplace` is the hostless fallback.\n //\n // The slug comes off the VALIDATED buyer org resolved at the top of the\n // handler now (AGL-2331) rather than a second, unvalidated `hostIndex`\n // read: one org id decides where the buyer lands, which org the licence\n // entitles, and which org's erasure sweeps the idempotency claim, so\n // having two derivations of it was a way for those three to disagree.\n const buyerOrgSlug = (\n await firestore.collection('orgs').doc(buyerOrgId).get()\n ).get('slug') as string | undefined\n // A site starter bought from Create site goes back to it (AGL-4049):\n // the workspace's sites page, which reopens Create site on the same\n // pick. Anything else, or a workspace with no slug to build that page\n // from, goes to the marketplace as before.\n const backToCreate =\n returnTo === 'create-site' && siteStarter && buyerOrgSlug\n ? siteCreateReturnQuery(listingId)\n : null\n const returnPath = backToCreate\n ? buildRoute(Route.HOST_LIST, { orgSlug: buyerOrgSlug as string })\n : buyerOrgSlug\n ? buildRoute(Route.ORG_MARKETPLACE, { orgSlug: buyerOrgSlug })\n : Route.ORG_MARKETPLACE\n // Each way back is built as a query, so `purchase` joins whatever the\n // page already asks for rather than being glued on as a second `?`.\n const returnUrl = (purchase: 'success' | 'canceled'): string => {\n const query = new URLSearchParams(backToCreate ?? undefined)\n query.set('purchase', purchase)\n return `${origin}${returnPath}?${query.toString()}`\n }\n // Tax + funds flow (AGL-1544). Aglyn registered as a marketplace\n // provider with the Texas Comptroller: it collects and remits sales tax\n // on sellers' behalf. That is the PLATFORM's charge — no `on_behalf_of`,\n // `automatic_tax` on the platform session, tax added ON TOP of the\n // listing price (`tax_behavior: exclusive`) with a full billing address\n // to compute from. The transfer is a FIXED amount (the seller's share of\n // the pre-tax price) rather than `application_fee_amount`, because the\n // fee form makes Stripe transfer `amount_total − fee` — and amount_total\n // includes the tax, which must stay with the platform that owes it.\n const params = new URLSearchParams({\n mode: 'payment',\n // 3-D Secure, from the one seam every card payment shares (AGL-3360).\n ...checkoutSessionCardAuthenticationParams('payment'),\n 'line_items[0][quantity]': '1',\n 'line_items[0][price_data][currency]': 'usd',\n 'line_items[0][price_data][unit_amount]': String(amountCents),\n 'line_items[0][price_data][tax_behavior]': 'exclusive',\n 'line_items[0][price_data][product_data][name]': String(\n listing.displayName ?? 'Marketplace component',\n ).slice(0, 120),\n // The classification, stated rather than inherited (AGL-1553). See\n // MARKETPLACE_TAX_CODE above: same value the account preset carries, so\n // the tax computed is unchanged, but it no longer depends on a Dashboard\n // field owned by Aglyn's subscription products.\n 'line_items[0][price_data][product_data][tax_code]': MARKETPLACE_TAX_CODE,\n 'automatic_tax[enabled]': 'true',\n billing_address_collection: 'required',\n // Nobody to pay when the platform's own org is the seller (AGL-4049):\n // the sale stays on the platform's account, tax and all.\n ...(platformSale\n ? {}\n : {\n 'payment_intent_data[transfer_data][destination]': String(accountId),\n 'payment_intent_data[transfer_data][amount]': String(transferCents),\n }),\n // THE DISCRIMINATOR THE REFUND DOOR HAS NO OTHER WAY TO GET (AGL-2148).\n //\n // `metadata[type]` below rides on the SESSION, and `charge.refunded`\n // never sees a session — its object is the charge. The platform\n // endpoint receives `charge.refunded` for storefront orders and\n // subscription charges too, so the webhook cannot tell a marketplace\n // refund from any other one except by joining on the payment intent,\n // which is exactly the join that comes up empty during the window this\n // metadata exists to close (the purchase document is written by\n // `checkout.session.completed`, and that delivery retries).\n //\n // Stripe copies a PaymentIntent's metadata onto the charge it creates,\n // so stamping it here makes `charge.metadata.type` readable straight\n // off the refund event — no Firestore join, no Stripe round trip, and\n // no orphan record written for the commerce and subscription refunds\n // that share this endpoint.\n //\n // `listingId`/`buyerUid` ride along for forensics only: an orphan that\n // never drains is a support case, and the payment intent alone does not\n // say whose purchase of what it was.\n 'payment_intent_data[metadata][type]': 'marketplace-purchase',\n 'payment_intent_data[metadata][listingId]': listingId,\n 'payment_intent_data[metadata][buyerUid]': decoded.uid,\n 'payment_intent_data[metadata][buyerOrgId]': buyerOrgId,\n success_url: returnUrl('success'),\n cancel_url: returnUrl('canceled'),\n client_reference_id: decoded.uid,\n 'metadata[type]': 'marketplace-purchase',\n 'metadata[listingId]': listingId,\n 'metadata[buyerUid]': decoded.uid,\n // THE ORG THE LICENCE IS FOR (AGL-2331). The webhook writes the\n // purchase document from this metadata and had no buyer org to write,\n // which is why the entitlement could only ever be person-scoped: the\n // one field the gate needs was never carried across the Stripe\n // boundary. Validated above against the caller's own membership, so a\n // session can never license an org the buyer has no standing in.\n 'metadata[buyerOrgId]': buyerOrgId,\n 'metadata[sellerOrgId]': sellerOrgId,\n 'metadata[feeCents]': String(feeCents),\n 'metadata[transferCents]': String(transferCents),\n // The buyer's GA client id (AGL-1638), so the server-side `purchase`\n // the webhook sends joins the browser session that produced it. The\n // webhook has always READ `metadata.ga_client_id`; nothing wrote it,\n // so the read was dead and every marketplace sale fell through to a\n // synthesized, sessionless GA user — making plugin revenue permanently\n // unattributable to any channel.\n //\n // Carried on the SESSION rather than a subscription because this is a\n // one-time payment; `checkout.session.completed` is where it is read.\n //\n // Validated to GA's `<digits>.<digits>` shape and dropped otherwise:\n // the value is client-supplied and ends up in Stripe metadata, so it is\n // not accepted on trust. Same guard /api/billing/checkout applies.\n ...(/^\\d+\\.\\d+$/.test(String(req.body?.gaClientId ?? ''))\n ? { 'metadata[ga_client_id]': String(req.body.gaClientId) }\n : {}),\n ...(decoded.email ? { customer_email: decoded.email } : {}),\n })\n // No buy-now-pay-later on a listing either (AGL-4198): a card or a\n // card-rate wallet, which the listing floor more than covers.\n excludeBuyNowPayLater(params)\n // Point of no return (AGL-1697). The purchase check above closes the\n // SEQUENTIAL duplicate — a second buy after the webhook has written the\n // first purchase — and cannot close the concurrent one, because between two\n // clicks a second apart there is no purchase record yet to find. The claim\n // is what covers that window: `create()` on a document Firestore refuses to\n // create twice.\n //\n // Scoped to the listing AND the buyer, so two people buying the same\n // component at the same moment never contend, and one person's two tabs\n // always do.\n //\n // The claim is taken here rather than at the top so that every deterministic\n // refusal above it — unknown listing, free listing, self-purchase, already\n // owned, publisher not paid out, seller cannot sell — answers without\n // burning the key. Those are all states a buyer or publisher fixes and then\n // presses the same button again.\n const claimed = await claimAttempt(firestore, {\n kind: 'marketplace-checkout',\n // Scoped to the ORG as well as the buyer since AGL-2331. The scope is\n // what decides which two attempts are allowed to contend, and a\n // licence for one workspace and a licence for another are now two\n // different goods — an agency owner buying for two clients from two\n // tabs must not have one of them read as a double-click of the other.\n scopeId: `${listingId}:${buyerOrgId}:${decoded.uid}`,\n // The BUYER's org, so `eraseOrgIdempotencyKeys` sweeps this with the\n // buyer's data (AGL-1448) — the seller keeps the sale.\n orgId: buyerOrgId,\n key: idempotencyKey,\n busyMessage: 'This purchase is already being processed',\n })\n if ('replay' in claimed) {\n return res.status(claimed.replay.status).json(claimed.replay.body)\n }\n claim = claimed.claim\n\n const response = await fetch(\n 'https://api.stripe.com/v1/checkout/sessions',\n {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,\n 'Content-Type': 'application/x-www-form-urlencoded',\n // The half that costs real money. Stripe replays the existing\n // session for a repeated key instead of opening a second one, which\n // covers the window where our own claim is written but the response\n // never arrives — the failure this route was most exposed to, since\n // it writes nothing of its own to reconcile against.\n ...(claim.stripeKey ? { 'Idempotency-Key': claim.stripeKey } : {}),\n },\n body: params.toString(),\n },\n )\n const session = (await response.json()) as { url?: string; error?: any }\n if (!response.ok || !session.url) {\n console.error('Stripe checkout error', session.error)\n // The purchase did not start — let the same key be tried again rather\n // than locking this buyer out of this listing over one flaky moment.\n await claim.release()\n return res.status(502).json({ error: 'Stripe checkout failed' })\n }\n const payload = { url: session.url }\n // A repeat of this attempt now replays the SAME session url. Better than\n // refusing it: the buyer lands back on the checkout they already had open.\n await claim.record(200, payload)\n return res.status(200).json(payload)\n } catch (error) {\n console.error(error)\n // Release on the way out so a transient failure does not strand the key\n // and lock the buyer out of this listing until they reload.\n await claim?.release()\n return res.status(500).json({ error: 'Checkout failed' })\n }\n}\n"],"names":["firebaseAdmin","checkoutSessionCardAuthenticationParams","buildRoute","claimAttempt","Route","siteCreateReturnQuery","excludeBuyNowPayLater","resolveOrgPermissions","readStarterCheckoutReturn","MARKETPLACE_TAX_CODE","resolveSale","checkoutHandler","req","res","method","status","json","error","process","env","STRIPE_SECRET_KEY","listingId","String","body","hostId","requestedOrgId","orgId","idempotencyKey","headers","trim","slice","returnTo","return","authorization","idToken","startsWith","length","undefined","claim","membership","listing","decoded","app","auth","verifyIdToken","uid","buyerOrgId","firestore","resolved","buyer","permissions","refusal","siteStarter","sellerOrgId","accountId","amountCents","feeCents","transferCents","platformSale","sale","origin","host","buyerOrgSlug","collection","doc","get","backToCreate","returnPath","HOST_LIST","orgSlug","ORG_MARKETPLACE","returnUrl","purchase","query","URLSearchParams","set","toString","params","mode","displayName","billing_address_collection","success_url","cancel_url","client_reference_id","test","gaClientId","email","customer_email","claimed","kind","scopeId","key","busyMessage","replay","response","fetch","Authorization","stripeKey","session","ok","url","console","release","payload","record"],"mappings":"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"}
@@ -0,0 +1,84 @@
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 PluginConnectionDeclaration } from '@aglyn/aglyn/app-utils/plugin-connections';
18
+ import { type PluginConnectionRecord } from './connection-store';
19
+ import type { InstalledPluginConnections } from './installed-connection';
20
+ import { revokeRecord } from './revoke';
21
+ /**
22
+ * A SITE'S CONNECTION GOES WHEN THE PLUGIN THAT DECLARED IT DOES (AGL-4194).
23
+ *
24
+ * A grant is held for one purpose: the plugin the site installed. When the
25
+ * site uninstalls that plugin, or moves to a version that no longer declares
26
+ * the connection, the grant is revoked at the provider (best effort) and the
27
+ * connection and its record are deleted. Nothing else of the plugin's is
28
+ * deleted on uninstall; this is a grant to someone else's account, which
29
+ * outlives its purpose the moment the plugin goes.
30
+ *
31
+ * Three readings of a pin, kept apart on purpose:
32
+ *
33
+ * - `declared`: the site runs a version that declares the connection;
34
+ * - `gone`: no pin (the site's own or its workspace's), or the pinned
35
+ * version declares no such connection. The connection is an orphan;
36
+ * - `paused`: the pinned version is revoked by the kill switch, or its
37
+ * manifest no longer reads. Nothing runs, and NOTHING is deleted: a
38
+ * takedown lifted tomorrow must find the site still connected.
39
+ *
40
+ * Called from the uninstall and install routes (`install-plugin.ts`) with the
41
+ * declarations the pin held before it moved, and from the scheduled sync for
42
+ * any orphan it meets.
43
+ */
44
+ export type PinnedConnectionState = {
45
+ state: 'declared';
46
+ installed: InstalledPluginConnections;
47
+ connection: PluginConnectionDeclaration;
48
+ } | {
49
+ state: 'gone';
50
+ } | {
51
+ state: 'paused';
52
+ };
53
+ /** Where a site's connection stands against the pin the site runs now. */
54
+ export declare function pinnedConnectionState(firestore: FirebaseFirestore.Firestore, input: {
55
+ hostId: string;
56
+ orgId: string | null;
57
+ listingId: string;
58
+ connectionId: string;
59
+ }): Promise<PinnedConnectionState>;
60
+ /**
61
+ * Revokes a connection's grant (best effort, with `declaration` when the pin
62
+ * that declared it is already gone) and deletes the connection and its record.
63
+ */
64
+ export declare function forgetSiteConnection(firestore: FirebaseFirestore.Firestore, record: Pick<PluginConnectionRecord, 'hostId' | 'listingId' | 'connectionId'> & Partial<PluginConnectionRecord>, input: {
65
+ orgId: string | null;
66
+ declaration?: PluginConnectionDeclaration | null;
67
+ revoke?: typeof revokeRecord;
68
+ }): Promise<{
69
+ revoked: boolean;
70
+ }>;
71
+ /**
72
+ * After a pin of `listingId` moved (removed, or re-pinned to another version)
73
+ * on `hostIds`: every connection of that listing a site holds that its pin no
74
+ * longer declares is revoked and deleted. `previous` is what the pin declared
75
+ * before it moved, for the revocation endpoint. Answers how many went.
76
+ */
77
+ export declare function reconcileListingConnections(firestore: FirebaseFirestore.Firestore, input: {
78
+ hostIds: readonly string[];
79
+ orgId: string | null;
80
+ listingId: string;
81
+ previous: readonly PluginConnectionDeclaration[];
82
+ }): Promise<number>;
83
+ /** The connections a pin's manifest declares, read defensively; `[]` for none or a manifest that does not read. */
84
+ export declare function pinConnections(pin: Record<string, unknown> | undefined | null): PluginConnectionDeclaration[];
@@ -0,0 +1,116 @@
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
+ */ import { findPluginConnection, validatePluginConnections } from "@aglyn/aglyn/app-utils/plugin-connections";
17
+ import { isPluginRevoked } from "@aglyn/aglyn/app-utils/plugin-manifest";
18
+ import { connectionRecordRef, connectionRef, listHostConnections } from "./connection-store.js";
19
+ import { announceConnectionRecordChange } from "./record-announce.js";
20
+ import { revokeRecord } from "./revoke.js";
21
+ /** Where a site's connection stands against the pin the site runs now. */ export async function pinnedConnectionState(firestore, input) {
22
+ var _pin_version, _pin_manifest, _ref, _pin_displayName;
23
+ const [hostPin, orgPin, revocation] = await Promise.all([
24
+ firestore.collection('hosts').doc(input.hostId).collection('installs').doc(input.listingId).get(),
25
+ input.orgId ? firestore.collection('orgs').doc(input.orgId).collection('installs').doc(input.listingId).get() : Promise.resolve(null),
26
+ firestore.collection('revocations').doc(input.listingId).get()
27
+ ]);
28
+ // The site's own pin wins over the workspace's, as it does for the loaders.
29
+ const pin = hostPin.exists ? hostPin.data() : (orgPin == null ? void 0 : orgPin.exists) ? orgPin.data() : undefined;
30
+ if (!pin) return {
31
+ state: 'gone'
32
+ };
33
+ const version = String((_pin_version = pin['version']) != null ? _pin_version : '');
34
+ if (!version) return {
35
+ state: 'paused'
36
+ };
37
+ if (isPluginRevoked(revocation.data(), version)) return {
38
+ state: 'paused'
39
+ };
40
+ const manifest = (_pin_manifest = pin['manifest']) != null ? _pin_manifest : null;
41
+ if ((manifest == null ? void 0 : manifest.connections) === undefined) return {
42
+ state: 'gone'
43
+ };
44
+ const verdict = validatePluginConnections(manifest.connections);
45
+ if (verdict.ok === false) return {
46
+ state: 'paused'
47
+ };
48
+ const connection = findPluginConnection({
49
+ connections: verdict.value
50
+ }, input.connectionId);
51
+ if (!connection) return {
52
+ state: 'gone'
53
+ };
54
+ return {
55
+ state: 'declared',
56
+ connection,
57
+ installed: {
58
+ listingId: input.listingId,
59
+ version,
60
+ displayName: String((_ref = (_pin_displayName = pin['displayName']) != null ? _pin_displayName : manifest.name) != null ? _ref : input.listingId).slice(0, 120),
61
+ connections: verdict.value
62
+ }
63
+ };
64
+ }
65
+ /**
66
+ * Revokes a connection's grant (best effort, with `declaration` when the pin
67
+ * that declared it is already gone) and deletes the connection and its record.
68
+ */ export async function forgetSiteConnection(firestore, record, input) {
69
+ var _input_revoke, _input_declaration;
70
+ const revoke = (_input_revoke = input.revoke) != null ? _input_revoke : revokeRecord;
71
+ const revoked = await revoke(record, input.orgId, (_input_declaration = input.declaration) != null ? _input_declaration : null);
72
+ await Promise.all([
73
+ connectionRef(firestore, record.hostId, record.listingId, record.connectionId).delete(),
74
+ connectionRecordRef(firestore, record.hostId, record.listingId, record.connectionId).delete()
75
+ ]);
76
+ await announceConnectionRecordChange(record.hostId, `plugin connection ${record.listingId}/${record.connectionId} removed`);
77
+ return {
78
+ revoked
79
+ };
80
+ }
81
+ /**
82
+ * After a pin of `listingId` moved (removed, or re-pinned to another version)
83
+ * on `hostIds`: every connection of that listing a site holds that its pin no
84
+ * longer declares is revoked and deleted. `previous` is what the pin declared
85
+ * before it moved, for the revocation endpoint. Answers how many went.
86
+ */ export async function reconcileListingConnections(firestore, input) {
87
+ let forgotten = 0;
88
+ for (const hostId of input.hostIds){
89
+ const records = (await listHostConnections(firestore, hostId)).filter((one)=>one.listingId === input.listingId);
90
+ for (const record of records){
91
+ var _input_previous_find;
92
+ const pinned = await pinnedConnectionState(firestore, {
93
+ hostId,
94
+ orgId: input.orgId,
95
+ listingId: input.listingId,
96
+ connectionId: record.connectionId
97
+ });
98
+ if (pinned.state !== 'gone') continue;
99
+ await forgetSiteConnection(firestore, record, {
100
+ orgId: input.orgId,
101
+ declaration: (_input_previous_find = input.previous.find((one)=>one.id === record.connectionId)) != null ? _input_previous_find : null
102
+ });
103
+ forgotten += 1;
104
+ }
105
+ }
106
+ return forgotten;
107
+ }
108
+ /** The connections a pin's manifest declares, read defensively; `[]` for none or a manifest that does not read. */ export function pinConnections(pin) {
109
+ var _pin_manifest;
110
+ const connections = pin == null ? void 0 : (_pin_manifest = pin['manifest']) == null ? void 0 : _pin_manifest.connections;
111
+ if (connections === undefined) return [];
112
+ const verdict = validatePluginConnections(connections);
113
+ return verdict.ok === false ? [] : verdict.value;
114
+ }
115
+
116
+ //# sourceMappingURL=cleanup.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/plugins/marketplace/src/lib/server/connections/cleanup.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\nimport {\n findPluginConnection,\n validatePluginConnections,\n type PluginConnectionDeclaration,\n} from '@aglyn/aglyn/app-utils/plugin-connections'\nimport { isPluginRevoked, type PluginRevocation } from '@aglyn/aglyn/app-utils/plugin-manifest'\nimport {\n connectionRecordRef,\n connectionRef,\n listHostConnections,\n type PluginConnectionRecord,\n} from './connection-store'\nimport type { InstalledPluginConnections } from './installed-connection'\nimport { announceConnectionRecordChange } from './record-announce'\nimport { revokeRecord } from './revoke'\n\n/**\n * A SITE'S CONNECTION GOES WHEN THE PLUGIN THAT DECLARED IT DOES (AGL-4194).\n *\n * A grant is held for one purpose: the plugin the site installed. When the\n * site uninstalls that plugin, or moves to a version that no longer declares\n * the connection, the grant is revoked at the provider (best effort) and the\n * connection and its record are deleted. Nothing else of the plugin's is\n * deleted on uninstall; this is a grant to someone else's account, which\n * outlives its purpose the moment the plugin goes.\n *\n * Three readings of a pin, kept apart on purpose:\n *\n * - `declared`: the site runs a version that declares the connection;\n * - `gone`: no pin (the site's own or its workspace's), or the pinned\n * version declares no such connection. The connection is an orphan;\n * - `paused`: the pinned version is revoked by the kill switch, or its\n * manifest no longer reads. Nothing runs, and NOTHING is deleted: a\n * takedown lifted tomorrow must find the site still connected.\n *\n * Called from the uninstall and install routes (`install-plugin.ts`) with the\n * declarations the pin held before it moved, and from the scheduled sync for\n * any orphan it meets.\n */\n\nexport type PinnedConnectionState =\n | { state: 'declared'; installed: InstalledPluginConnections; connection: PluginConnectionDeclaration }\n | { state: 'gone' }\n | { state: 'paused' }\n\n/** Where a site's connection stands against the pin the site runs now. */\nexport async function pinnedConnectionState(\n firestore: FirebaseFirestore.Firestore,\n input: { hostId: string; orgId: string | null; listingId: string; connectionId: string },\n): Promise<PinnedConnectionState> {\n const [hostPin, orgPin, revocation] = await Promise.all([\n firestore.collection('hosts').doc(input.hostId).collection('installs').doc(input.listingId).get(),\n input.orgId\n ? firestore.collection('orgs').doc(input.orgId).collection('installs').doc(input.listingId).get()\n : Promise.resolve(null),\n firestore.collection('revocations').doc(input.listingId).get(),\n ])\n // The site's own pin wins over the workspace's, as it does for the loaders.\n const pin = hostPin.exists ? hostPin.data() : orgPin?.exists ? orgPin.data() : undefined\n if (!pin) return { state: 'gone' }\n const version = String(pin['version'] ?? '')\n if (!version) return { state: 'paused' }\n if (isPluginRevoked(revocation.data() as PluginRevocation | undefined, version)) return { state: 'paused' }\n const manifest = (pin['manifest'] ?? null) as { connections?: unknown; name?: unknown } | null\n if (manifest?.connections === undefined) return { state: 'gone' }\n const verdict = validatePluginConnections(manifest.connections)\n if (verdict.ok === false) return { state: 'paused' }\n const connection = findPluginConnection({ connections: verdict.value }, input.connectionId)\n if (!connection) return { state: 'gone' }\n return {\n state: 'declared',\n connection,\n installed: {\n listingId: input.listingId,\n version,\n displayName: String(pin['displayName'] ?? manifest.name ?? input.listingId).slice(0, 120),\n connections: verdict.value,\n },\n }\n}\n\n/**\n * Revokes a connection's grant (best effort, with `declaration` when the pin\n * that declared it is already gone) and deletes the connection and its record.\n */\nexport async function forgetSiteConnection(\n firestore: FirebaseFirestore.Firestore,\n record: Pick<PluginConnectionRecord, 'hostId' | 'listingId' | 'connectionId'> & Partial<PluginConnectionRecord>,\n input: { orgId: string | null; declaration?: PluginConnectionDeclaration | null; revoke?: typeof revokeRecord },\n): Promise<{ revoked: boolean }> {\n const revoke = input.revoke ?? revokeRecord\n const revoked = await revoke(record as PluginConnectionRecord, input.orgId, input.declaration ?? null)\n await Promise.all([\n connectionRef(firestore, record.hostId, record.listingId, record.connectionId).delete(),\n connectionRecordRef(firestore, record.hostId, record.listingId, record.connectionId).delete(),\n ])\n await announceConnectionRecordChange(record.hostId, `plugin connection ${record.listingId}/${record.connectionId} removed`)\n return { revoked }\n}\n\n/**\n * After a pin of `listingId` moved (removed, or re-pinned to another version)\n * on `hostIds`: every connection of that listing a site holds that its pin no\n * longer declares is revoked and deleted. `previous` is what the pin declared\n * before it moved, for the revocation endpoint. Answers how many went.\n */\nexport async function reconcileListingConnections(\n firestore: FirebaseFirestore.Firestore,\n input: {\n hostIds: readonly string[]\n orgId: string | null\n listingId: string\n previous: readonly PluginConnectionDeclaration[]\n },\n): Promise<number> {\n let forgotten = 0\n for (const hostId of input.hostIds) {\n const records = (await listHostConnections(firestore, hostId)).filter((one) => one.listingId === input.listingId)\n for (const record of records) {\n const pinned = await pinnedConnectionState(firestore, {\n hostId,\n orgId: input.orgId,\n listingId: input.listingId,\n connectionId: record.connectionId,\n })\n if (pinned.state !== 'gone') continue\n await forgetSiteConnection(firestore, record, {\n orgId: input.orgId,\n declaration: input.previous.find((one) => one.id === record.connectionId) ?? null,\n })\n forgotten += 1\n }\n }\n return forgotten\n}\n\n/** The connections a pin's manifest declares, read defensively; `[]` for none or a manifest that does not read. */\nexport function pinConnections(pin: Record<string, unknown> | undefined | null): PluginConnectionDeclaration[] {\n const connections = (pin?.['manifest'] as { connections?: unknown } | undefined)?.connections\n if (connections === undefined) return []\n const verdict = validatePluginConnections(connections)\n return verdict.ok === false ? [] : verdict.value\n}\n"],"names":["findPluginConnection","validatePluginConnections","isPluginRevoked","connectionRecordRef","connectionRef","listHostConnections","announceConnectionRecordChange","revokeRecord","pinnedConnectionState","firestore","input","pin","hostPin","orgPin","revocation","Promise","all","collection","doc","hostId","listingId","get","orgId","resolve","exists","data","undefined","state","version","String","manifest","connections","verdict","ok","connection","value","connectionId","installed","displayName","name","slice","forgetSiteConnection","record","revoke","revoked","declaration","delete","reconcileListingConnections","forgotten","hostIds","records","filter","one","pinned","previous","find","id","pinConnections"],"mappings":"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"}
@@ -0,0 +1,42 @@
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
+ /**
19
+ * THE ONE MODULE THAT READS DECLARED CONNECTIONS' KEY FROM THE ENVIRONMENT
20
+ * (AGL-4193).
21
+ *
22
+ * - `PLUGIN_CONNECTIONS_TOKEN_KEY` — 32 random bytes, base64
23
+ * (`openssl rand -base64 32`), sealing every publisher OAuth client id and
24
+ * secret and every site's refresh and access token with `secret-box`. A
25
+ * comma-separated list rotates: the first key seals, every key listed opens.
26
+ *
27
+ * There is no provider credential here: the OAuth app is each PUBLISHER's,
28
+ * stored per listing (`credentials-store.ts`). Unset, connections are off on
29
+ * the deployment: every connect route answers 503 and the status route says
30
+ * so, and nothing is ever stored unsealed. Read only by the console's server,
31
+ * in the bracket form, so Next never inlines a value into a build.
32
+ */
33
+ export declare const PLUGIN_CONNECTIONS_TOKEN_KEY_ENV = "PLUGIN_CONNECTIONS_TOKEN_KEY";
34
+ /** The keyring, or `null` when the key is unset or unusable. Never throws. */
35
+ export declare function readPluginConnectionsKeyring(): SecretBoxKeyring | null;
36
+ /**
37
+ * A listing id: a resource uid (`[A-Za-z0-9_-]`). A connection id never holds
38
+ * an underscore, so `{listingId}__{connectionId}` splits at its LAST `__`.
39
+ */
40
+ export declare const isListingId: (value: unknown) => value is string;
41
+ /** A site id, as every host route reads one. */
42
+ export declare const isHostId: (value: unknown) => value is string;
@@ -0,0 +1,48 @@
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
+ */ import { parseSecretBoxKeyring } from "@aglyn/shared-util-tools/secret-box";
17
+ /**
18
+ * THE ONE MODULE THAT READS DECLARED CONNECTIONS' KEY FROM THE ENVIRONMENT
19
+ * (AGL-4193).
20
+ *
21
+ * - `PLUGIN_CONNECTIONS_TOKEN_KEY` — 32 random bytes, base64
22
+ * (`openssl rand -base64 32`), sealing every publisher OAuth client id and
23
+ * secret and every site's refresh and access token with `secret-box`. A
24
+ * comma-separated list rotates: the first key seals, every key listed opens.
25
+ *
26
+ * There is no provider credential here: the OAuth app is each PUBLISHER's,
27
+ * stored per listing (`credentials-store.ts`). Unset, connections are off on
28
+ * the deployment: every connect route answers 503 and the status route says
29
+ * so, and nothing is ever stored unsealed. Read only by the console's server,
30
+ * in the bracket form, so Next never inlines a value into a build.
31
+ */ export const PLUGIN_CONNECTIONS_TOKEN_KEY_ENV = 'PLUGIN_CONNECTIONS_TOKEN_KEY';
32
+ /** The keyring, or `null` when the key is unset or unusable. Never throws. */ export function readPluginConnectionsKeyring() {
33
+ var _process_env_PLUGIN_CONNECTIONS_TOKEN_KEY_ENV;
34
+ const value = String((_process_env_PLUGIN_CONNECTIONS_TOKEN_KEY_ENV = process.env[PLUGIN_CONNECTIONS_TOKEN_KEY_ENV]) != null ? _process_env_PLUGIN_CONNECTIONS_TOKEN_KEY_ENV : '').trim();
35
+ if (!value) return null;
36
+ try {
37
+ return parseSecretBoxKeyring(value);
38
+ } catch (unused) {
39
+ return null;
40
+ }
41
+ }
42
+ /**
43
+ * A listing id: a resource uid (`[A-Za-z0-9_-]`). A connection id never holds
44
+ * an underscore, so `{listingId}__{connectionId}` splits at its LAST `__`.
45
+ */ export const isListingId = (value)=>typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value);
46
+ /** A site id, as every host route reads one. */ export const isHostId = (value)=>typeof value === 'string' && value.length > 0 && value.length <= 200 && !value.includes('/') && !/^__.*__$/.test(value);
47
+
48
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/plugins/marketplace/src/lib/server/connections/config.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\nimport { parseSecretBoxKeyring, type SecretBoxKeyring } from '@aglyn/shared-util-tools/secret-box'\n\n/**\n * THE ONE MODULE THAT READS DECLARED CONNECTIONS' KEY FROM THE ENVIRONMENT\n * (AGL-4193).\n *\n * - `PLUGIN_CONNECTIONS_TOKEN_KEY` — 32 random bytes, base64\n * (`openssl rand -base64 32`), sealing every publisher OAuth client id and\n * secret and every site's refresh and access token with `secret-box`. A\n * comma-separated list rotates: the first key seals, every key listed opens.\n *\n * There is no provider credential here: the OAuth app is each PUBLISHER's,\n * stored per listing (`credentials-store.ts`). Unset, connections are off on\n * the deployment: every connect route answers 503 and the status route says\n * so, and nothing is ever stored unsealed. Read only by the console's server,\n * in the bracket form, so Next never inlines a value into a build.\n */\n\nexport const PLUGIN_CONNECTIONS_TOKEN_KEY_ENV = 'PLUGIN_CONNECTIONS_TOKEN_KEY'\n\n/** The keyring, or `null` when the key is unset or unusable. Never throws. */\nexport function readPluginConnectionsKeyring(): SecretBoxKeyring | null {\n const value = String(process.env[PLUGIN_CONNECTIONS_TOKEN_KEY_ENV] ?? '').trim()\n if (!value) return null\n try {\n return parseSecretBoxKeyring(value)\n } catch {\n return null\n }\n}\n\n/**\n * A listing id: a resource uid (`[A-Za-z0-9_-]`). A connection id never holds\n * an underscore, so `{listingId}__{connectionId}` splits at its LAST `__`.\n */\nexport const isListingId = (value: unknown): value is string =>\n typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value)\n\n/** A site id, as every host route reads one. */\nexport const isHostId = (value: unknown): value is string =>\n typeof value === 'string' && value.length > 0 && value.length <= 200 && !value.includes('/') && !/^__.*__$/.test(value)\n"],"names":["parseSecretBoxKeyring","PLUGIN_CONNECTIONS_TOKEN_KEY_ENV","readPluginConnectionsKeyring","process","value","String","env","trim","isListingId","test","isHostId","length","includes"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,qBAAqB,QAA+B,sCAAqC;AAElG;;;;;;;;;;;;;;CAcC,GAED,OAAO,MAAMC,mCAAmC,+BAA8B;AAE9E,4EAA4E,GAC5E,OAAO,SAASC;QACOC;IAArB,MAAMC,QAAQC,QAAOF,gDAAAA,QAAQG,GAAG,CAACL,iCAAiC,YAA7CE,gDAAiD,IAAII,IAAI;IAC9E,IAAI,CAACH,OAAO,OAAO;IACnB,IAAI;QACF,OAAOJ,sBAAsBI;IAC/B,EAAE,eAAM;QACN,OAAO;IACT;AACF;AAEA;;;CAGC,GACD,OAAO,MAAMI,cAAc,CAACJ,QAC1B,OAAOA,UAAU,YAAY,yBAAyBK,IAAI,CAACL,OAAM;AAEnE,8CAA8C,GAC9C,OAAO,MAAMM,WAAW,CAACN,QACvB,OAAOA,UAAU,YAAYA,MAAMO,MAAM,GAAG,KAAKP,MAAMO,MAAM,IAAI,OAAO,CAACP,MAAMQ,QAAQ,CAAC,QAAQ,CAAC,WAAWH,IAAI,CAACL,OAAM"}