@aglyn/plugins-marketplace 1.0.0-beta.143

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 (165) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +7 -0
  3. package/package.json +51 -0
  4. package/src/index.d.ts +19 -0
  5. package/src/index.js +20 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/components/artifact-update-dialog.component.d.ts +27 -0
  8. package/src/lib/components/artifact-update-dialog.component.js +244 -0
  9. package/src/lib/components/artifact-update-dialog.component.js.map +1 -0
  10. package/src/lib/components/host-plugins-card.component.d.ts +23 -0
  11. package/src/lib/components/host-plugins-card.component.js +566 -0
  12. package/src/lib/components/host-plugins-card.component.js.map +1 -0
  13. package/src/lib/components/listing-content.component.d.ts +57 -0
  14. package/src/lib/components/listing-content.component.js +1880 -0
  15. package/src/lib/components/listing-content.component.js.map +1 -0
  16. package/src/lib/components/listing-image.component.d.ts +46 -0
  17. package/src/lib/components/listing-image.component.js +38 -0
  18. package/src/lib/components/listing-image.component.js.map +1 -0
  19. package/src/lib/components/listing-reviews.component.d.ts +22 -0
  20. package/src/lib/components/listing-reviews.component.js +419 -0
  21. package/src/lib/components/listing-reviews.component.js.map +1 -0
  22. package/src/lib/components/marketplace-browse.component.d.ts +38 -0
  23. package/src/lib/components/marketplace-browse.component.js +802 -0
  24. package/src/lib/components/marketplace-browse.component.js.map +1 -0
  25. package/src/lib/components/plugin-site-set-panel.component.d.ts +24 -0
  26. package/src/lib/components/plugin-site-set-panel.component.js +137 -0
  27. package/src/lib/components/plugin-site-set-panel.component.js.map +1 -0
  28. package/src/lib/components/plugin-site-set.component.d.ts +43 -0
  29. package/src/lib/components/plugin-site-set.component.js +425 -0
  30. package/src/lib/components/plugin-site-set.component.js.map +1 -0
  31. package/src/lib/components/rating-input.component.d.ts +3 -0
  32. package/src/lib/components/rating-input.component.js +47 -0
  33. package/src/lib/components/rating-input.component.js.map +1 -0
  34. package/src/lib/components/report-target.component.d.ts +36 -0
  35. package/src/lib/components/report-target.component.js +181 -0
  36. package/src/lib/components/report-target.component.js.map +1 -0
  37. package/src/lib/components/uninstall-impact-dialog.component.d.ts +40 -0
  38. package/src/lib/components/uninstall-impact-dialog.component.js +244 -0
  39. package/src/lib/components/uninstall-impact-dialog.component.js.map +1 -0
  40. package/src/lib/constants/bundle-common.d.ts +8 -0
  41. package/src/lib/constants/bundle-common.js +9 -0
  42. package/src/lib/constants/bundle-common.js.map +1 -0
  43. package/src/lib/hooks/use-artifact-update.d.ts +41 -0
  44. package/src/lib/hooks/use-artifact-update.js +169 -0
  45. package/src/lib/hooks/use-artifact-update.js.map +1 -0
  46. package/src/lib/hooks/use-marketplace-actions.d.ts +37 -0
  47. package/src/lib/hooks/use-marketplace-actions.js +523 -0
  48. package/src/lib/hooks/use-marketplace-actions.js.map +1 -0
  49. package/src/lib/model/artifact-merge.d.ts +154 -0
  50. package/src/lib/model/artifact-merge.js +228 -0
  51. package/src/lib/model/artifact-merge.js.map +1 -0
  52. package/src/lib/model/index.d.ts +43 -0
  53. package/src/lib/model/index.js +41 -0
  54. package/src/lib/model/index.js.map +1 -0
  55. package/src/lib/model/marketplace-props.d.ts +57 -0
  56. package/src/lib/model/marketplace-props.js +441 -0
  57. package/src/lib/model/marketplace-props.js.map +1 -0
  58. package/src/lib/model/marketplace.d.ts +833 -0
  59. package/src/lib/model/marketplace.js +929 -0
  60. package/src/lib/model/marketplace.js.map +1 -0
  61. package/src/lib/model/rating-field.d.ts +24 -0
  62. package/src/lib/model/rating-field.js +33 -0
  63. package/src/lib/model/rating-field.js.map +1 -0
  64. package/src/lib/plugin.d.ts +33 -0
  65. package/src/lib/plugin.js +86 -0
  66. package/src/lib/plugin.js.map +1 -0
  67. package/src/lib/server/billing-webhook.d.ts +25 -0
  68. package/src/lib/server/billing-webhook.js +1062 -0
  69. package/src/lib/server/billing-webhook.js.map +1 -0
  70. package/src/lib/server/checkout.d.ts +76 -0
  71. package/src/lib/server/checkout.js +477 -0
  72. package/src/lib/server/checkout.js.map +1 -0
  73. package/src/lib/server/connect.d.ts +24 -0
  74. package/src/lib/server/connect.js +205 -0
  75. package/src/lib/server/connect.js.map +1 -0
  76. package/src/lib/server/install-dataset-schema.d.ts +32 -0
  77. package/src/lib/server/install-dataset-schema.js +255 -0
  78. package/src/lib/server/install-dataset-schema.js.map +1 -0
  79. package/src/lib/server/install-email-starter.d.ts +65 -0
  80. package/src/lib/server/install-email-starter.js +279 -0
  81. package/src/lib/server/install-email-starter.js.map +1 -0
  82. package/src/lib/server/install-email-template.d.ts +34 -0
  83. package/src/lib/server/install-email-template.js +252 -0
  84. package/src/lib/server/install-email-template.js.map +1 -0
  85. package/src/lib/server/install-layout.d.ts +29 -0
  86. package/src/lib/server/install-layout.js +323 -0
  87. package/src/lib/server/install-layout.js.map +1 -0
  88. package/src/lib/server/install-pin-counts.d.ts +173 -0
  89. package/src/lib/server/install-pin-counts.js +248 -0
  90. package/src/lib/server/install-pin-counts.js.map +1 -0
  91. package/src/lib/server/install-plugin.d.ts +18 -0
  92. package/src/lib/server/install-plugin.js +405 -0
  93. package/src/lib/server/install-plugin.js.map +1 -0
  94. package/src/lib/server/install-template.d.ts +37 -0
  95. package/src/lib/server/install-template.js +348 -0
  96. package/src/lib/server/install-template.js.map +1 -0
  97. package/src/lib/server/install-theme.d.ts +42 -0
  98. package/src/lib/server/install-theme.js +313 -0
  99. package/src/lib/server/install-theme.js.map +1 -0
  100. package/src/lib/server/install.d.ts +27 -0
  101. package/src/lib/server/install.js +276 -0
  102. package/src/lib/server/install.js.map +1 -0
  103. package/src/lib/server/listing-versions.d.ts +26 -0
  104. package/src/lib/server/listing-versions.js +378 -0
  105. package/src/lib/server/listing-versions.js.map +1 -0
  106. package/src/lib/server/preview-image.d.ts +37 -0
  107. package/src/lib/server/preview-image.js +216 -0
  108. package/src/lib/server/preview-image.js.map +1 -0
  109. package/src/lib/server/provenance.d.ts +100 -0
  110. package/src/lib/server/provenance.js +118 -0
  111. package/src/lib/server/provenance.js.map +1 -0
  112. package/src/lib/server/publish-dataset-schema.d.ts +31 -0
  113. package/src/lib/server/publish-dataset-schema.js +202 -0
  114. package/src/lib/server/publish-dataset-schema.js.map +1 -0
  115. package/src/lib/server/publish-email-starter.d.ts +44 -0
  116. package/src/lib/server/publish-email-starter.js +231 -0
  117. package/src/lib/server/publish-email-starter.js.map +1 -0
  118. package/src/lib/server/publish-email-template.d.ts +33 -0
  119. package/src/lib/server/publish-email-template.js +211 -0
  120. package/src/lib/server/publish-email-template.js.map +1 -0
  121. package/src/lib/server/publish-layout.d.ts +33 -0
  122. package/src/lib/server/publish-layout.js +243 -0
  123. package/src/lib/server/publish-layout.js.map +1 -0
  124. package/src/lib/server/publish-plugin.d.ts +18 -0
  125. package/src/lib/server/publish-plugin.js +485 -0
  126. package/src/lib/server/publish-plugin.js.map +1 -0
  127. package/src/lib/server/publish-preconditions.d.ts +86 -0
  128. package/src/lib/server/publish-preconditions.js +160 -0
  129. package/src/lib/server/publish-preconditions.js.map +1 -0
  130. package/src/lib/server/publish-template.d.ts +27 -0
  131. package/src/lib/server/publish-template.js +215 -0
  132. package/src/lib/server/publish-template.js.map +1 -0
  133. package/src/lib/server/publish-theme.d.ts +38 -0
  134. package/src/lib/server/publish-theme.js +186 -0
  135. package/src/lib/server/publish-theme.js.map +1 -0
  136. package/src/lib/server/publish.d.ts +26 -0
  137. package/src/lib/server/publish.js +204 -0
  138. package/src/lib/server/publish.js.map +1 -0
  139. package/src/lib/server/publisher-profile-save.d.ts +30 -0
  140. package/src/lib/server/publisher-profile-save.js +226 -0
  141. package/src/lib/server/publisher-profile-save.js.map +1 -0
  142. package/src/lib/server/publisher-profile.d.ts +86 -0
  143. package/src/lib/server/publisher-profile.js +116 -0
  144. package/src/lib/server/publisher-profile.js.map +1 -0
  145. package/src/lib/server/purchase-entitlement.d.ts +83 -0
  146. package/src/lib/server/purchase-entitlement.js +118 -0
  147. package/src/lib/server/purchase-entitlement.js.map +1 -0
  148. package/src/lib/server/report.d.ts +32 -0
  149. package/src/lib/server/report.js +101 -0
  150. package/src/lib/server/report.js.map +1 -0
  151. package/src/lib/server/reviews.d.ts +41 -0
  152. package/src/lib/server/reviews.js +240 -0
  153. package/src/lib/server/reviews.js.map +1 -0
  154. package/src/lib/server/update-artifact.d.ts +19 -0
  155. package/src/lib/server/update-artifact.js +569 -0
  156. package/src/lib/server/update-artifact.js.map +1 -0
  157. package/src/lib/server/verification-request.d.ts +31 -0
  158. package/src/lib/server/verification-request.js +173 -0
  159. package/src/lib/server/verification-request.js.map +1 -0
  160. package/src/lib/server/version-stats.d.ts +202 -0
  161. package/src/lib/server/version-stats.js +305 -0
  162. package/src/lib/server/version-stats.js.map +1 -0
  163. package/src/lib/server.d.ts +30 -0
  164. package/src/lib/server.js +95 -0
  165. package/src/lib/server.js.map +1 -0
@@ -0,0 +1,216 @@
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 { isFirstPartyMediaSrc } from "@aglyn/aglyn/app-utils/media-ref";
17
+ import { isImageUploadContentType, normalizeImageContentType } from "@aglyn/aglyn/app-utils/image-upload-types";
18
+ import { isSvgUploadType, sanitizeSvgBuffer } from "@aglyn/aglyn/app-utils/sanitize-svg";
19
+ import { inspectUploadBytes } from "@aglyn/aglyn/server";
20
+ import { firebaseAdmin, isImpersonationSession } from "@aglyn/tenant-data-admin";
21
+ import { randomUUID } from "crypto";
22
+ import { canActAsPublisher } from "./publisher-profile.js";
23
+ // Base64 JSON payloads: 2MB of image encodes to ~2.7MB of body.
24
+ const MAX_FILE_BYTES = 2 * 1024 * 1024;
25
+ const PREVIEW_PATH = (listingId)=>`marketplaceListings/${listingId}/preview`;
26
+ /**
27
+ * Listing preview image (AGL-95): the publisher uploads one screenshot per
28
+ * listing, shown on browse cards and the detail page. Storage rules deny
29
+ * client writes, so the mutation runs here — Firebase ID token, listing
30
+ * ownership, image-only, 2MB cap. POST replaces the image and sets
31
+ * `previewImageUrl` on the listing; DELETE removes both.
32
+ *
33
+ * Lives in the plugin, like every other `marketplace/*` operation. It was the
34
+ * one that didn't: it sat in the console app and imported this plugin's
35
+ * `canActAsPublisher` across the `scope:app` → `aglyn:addons` boundary, which
36
+ * failed lint on main. Registered at the same path, so the URL clients call
37
+ * is unchanged — `/api/marketplace/preview-image` reaches the plugin catch-all
38
+ * with exactly the shape it had as a named app route.
39
+ *
40
+ * The email-verified gate (AGL-479) is carried over deliberately: the plugin
41
+ * API dispatcher does NOT apply it centrally, so dropping it here would have
42
+ * quietly widened who can write listing images.
43
+ */ export const previewImageHandler = async (req, res)=>{
44
+ var _ref, _req_headers_authorization;
45
+ var _req_body;
46
+ if (req.method !== 'POST' && req.method !== 'DELETE') {
47
+ return res.status(405).json({
48
+ error: 'Method not allowed'
49
+ });
50
+ }
51
+ const listingId = String((_ref = (_req_body = req.body) == null ? void 0 : _req_body.listingId) != null ? _ref : '');
52
+ if (!listingId) return res.status(400).json({
53
+ error: 'Missing listingId'
54
+ });
55
+ const authorization = String((_req_headers_authorization = req.headers.authorization) != null ? _req_headers_authorization : '');
56
+ const idToken = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : undefined;
57
+ if (!idToken) return res.status(401).json({
58
+ error: 'Unauthenticated'
59
+ });
60
+ try {
61
+ var _ref1, _ref2, _ref3;
62
+ var _req_body1, _req_body2, _req_body3, _req_body4;
63
+ const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
64
+ if (!decoded.email_verified && !isImpersonationSession(decoded)) {
65
+ return res.status(403).json({
66
+ error: 'Verify your email to continue',
67
+ reason: 'email-unverified'
68
+ });
69
+ }
70
+ const firestore = firebaseAdmin.app().firestore();
71
+ const listingRef = firestore.collection('marketplaceListings').doc(listingId);
72
+ const listingSnapshot = await listingRef.get();
73
+ if (!listingSnapshot.exists) {
74
+ return res.status(404).json({
75
+ error: 'Unknown listing'
76
+ });
77
+ }
78
+ // Listings are org-owned since AGL-652, so `profileId` holds an ORG id.
79
+ // Comparing it to a uid can never be true — it silently 403'd every
80
+ // publisher rather than erroring, which is why this survived the sweep.
81
+ const canEdit = await canActAsPublisher(firestore, decoded.uid, listingSnapshot.get('profileId'));
82
+ if (!canEdit) {
83
+ return res.status(403).json({
84
+ error: 'Not your listing'
85
+ });
86
+ }
87
+ const bucket = firebaseAdmin.app().storage().bucket(process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET);
88
+ const file = bucket.file(PREVIEW_PATH(listingId));
89
+ if (req.method === 'DELETE') {
90
+ await file.delete().catch(()=>undefined);
91
+ await listingRef.set({
92
+ previewImageUrl: firebaseAdmin.firestore.FieldValue.delete(),
93
+ updatedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp()
94
+ }, {
95
+ merge: true
96
+ });
97
+ return res.status(200).json({
98
+ deleted: true
99
+ });
100
+ }
101
+ // DAM-hosted asset (AGL-863): the publisher picked an existing asset from
102
+ // the media library instead of uploading raw bytes. It already lives in
103
+ // our storage/CDN, so point `previewImageUrl` at it directly — no re-upload
104
+ // and no duplicate blob.
105
+ //
106
+ // The comment here used to claim parity with logo/screenshot, "which
107
+ // already accept any https URL". That was true and was the bug (AGL-1701):
108
+ // the preview image is the picture on the cross-org BROWSE grid and the
109
+ // `og:image` an unauthenticated unfurler fetches, so "any https URL" made
110
+ // a publisher-controlled host the recipient of every browser's IP. Parity
111
+ // is restored in the other direction — this field now takes what the
112
+ // sentence always implied it took, an asset that lives in our library.
113
+ const providedUrl = String((_ref1 = (_req_body1 = req.body) == null ? void 0 : _req_body1.url) != null ? _ref1 : '').trim();
114
+ if (providedUrl) {
115
+ if (providedUrl.length > 2000 || !isFirstPartyMediaSrc(providedUrl)) {
116
+ return res.status(400).json({
117
+ error: 'Preview image must come from your media library — pick one with ' + 'Browse, or upload the file here'
118
+ });
119
+ }
120
+ await listingRef.set({
121
+ previewImageUrl: providedUrl,
122
+ updatedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp()
123
+ }, {
124
+ merge: true
125
+ });
126
+ return res.status(200).json({
127
+ url: providedUrl
128
+ });
129
+ }
130
+ const contentType = normalizeImageContentType((_req_body2 = req.body) == null ? void 0 : _req_body2.contentType);
131
+ const data = String((_ref2 = (_req_body3 = req.body) == null ? void 0 : _req_body3.data) != null ? _ref2 : '');
132
+ // An ALLOWLIST rather than the `image/` prefix, for the same
133
+ // reason as the DAM routes: the prefix tested a caller-supplied string,
134
+ // so an unrecognised `image/…` label reached storage AND skipped the
135
+ // signature check below, which can only compare bytes against a type it
136
+ // knows.
137
+ if (!isImageUploadContentType(contentType)) {
138
+ return res.status(415).json({
139
+ error: 'Only image uploads are supported'
140
+ });
141
+ }
142
+ const buffer = Buffer.from(data, 'base64');
143
+ if (!buffer.length || buffer.length > MAX_FILE_BYTES) {
144
+ return res.status(413).json({
145
+ error: 'File is empty or too large (2MB)'
146
+ });
147
+ }
148
+ /**
149
+ * STRUCTURAL inspection (AGL-1475). The FIFTH byte ingress, and the one
150
+ * least like the other four.
151
+ *
152
+ * `contentType.startsWith('image/')` above is the entire type gate, and
153
+ * it is taken verbatim from the request body — so `image/png` over a
154
+ * Windows executable satisfied it. Worse than on the DAM routes: the URL
155
+ * this mints points straight at `firebasestorage.googleapis.com` rather
156
+ * than through `serveMediaCdn`, so the stored object never gets that
157
+ * route's `nosniff` and per-type CSP either. And it is the picture on the
158
+ * cross-org BROWSE grid and the `og:image` an unauthenticated unfurler
159
+ * fetches — the same reasoning that hardened the URL branch in AGL-1701,
160
+ * applied at last to the bytes branch.
161
+ *
162
+ * Structure only — not an antivirus scan. See `upload-inspection.ts`.
163
+ */ const refusal = inspectUploadBytes({
164
+ bytes: buffer,
165
+ contentType,
166
+ fileName: String((_ref3 = (_req_body4 = req.body) == null ? void 0 : _req_body4.fileName) != null ? _ref3 : '') || null
167
+ });
168
+ if (refusal) {
169
+ return res.status(415).json({
170
+ error: refusal.message,
171
+ code: refusal.code
172
+ });
173
+ }
174
+ /**
175
+ * SVG sanitization. Structural inspection cannot do this: an
176
+ * SVG is text, it has no magic number, and a `<script>` in one is a
177
+ * perfectly well-formed document.
178
+ *
179
+ * It matters more here than on the DAM routes, not less. Those serve
180
+ * through `serveMediaCdn`, which stamps `nosniff` and a `sandbox` CSP on
181
+ * every response and so contains a scripted SVG even if it is stored. The
182
+ * URL this mints points straight at `firebasestorage.googleapis.com` and
183
+ * gets neither header — and it is the `og:image` an unauthenticated
184
+ * unfurler fetches and the picture on the cross-org browse grid, so the
185
+ * audience is every other publisher's browser rather than the uploader's
186
+ * own.
187
+ */ const bytes = isSvgUploadType(contentType) ? sanitizeSvgBuffer(buffer).buffer : buffer;
188
+ const token = randomUUID();
189
+ await file.save(bytes, {
190
+ contentType,
191
+ metadata: {
192
+ metadata: {
193
+ firebaseStorageDownloadTokens: token
194
+ }
195
+ }
196
+ });
197
+ const url = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/` + `${encodeURIComponent(PREVIEW_PATH(listingId))}` + `?alt=media&token=${token}`;
198
+ await listingRef.set({
199
+ previewImageUrl: url,
200
+ updatedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp()
201
+ }, {
202
+ merge: true
203
+ });
204
+ return res.status(200).json({
205
+ url
206
+ });
207
+ } catch (error) {
208
+ console.error(error);
209
+ return res.status(500).json({
210
+ error: 'Upload failed'
211
+ });
212
+ }
213
+ };
214
+ export default previewImageHandler;
215
+
216
+ //# sourceMappingURL=preview-image.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/preview-image.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 { isFirstPartyMediaSrc } from '@aglyn/aglyn/app-utils/media-ref'\nimport {\n isImageUploadContentType,\n normalizeImageContentType,\n} from '@aglyn/aglyn/app-utils/image-upload-types'\nimport {\n isSvgUploadType,\n sanitizeSvgBuffer,\n} from '@aglyn/aglyn/app-utils/sanitize-svg'\nimport { inspectUploadBytes, type PluginApiHandler } from '@aglyn/aglyn/server'\nimport { firebaseAdmin, isImpersonationSession } from '@aglyn/tenant-data-admin'\nimport { randomUUID } from 'crypto'\nimport { canActAsPublisher } from './publisher-profile'\n\n// Base64 JSON payloads: 2MB of image encodes to ~2.7MB of body.\nconst MAX_FILE_BYTES = 2 * 1024 * 1024\n\nconst PREVIEW_PATH = (listingId: string) =>\n `marketplaceListings/${listingId}/preview`\n\n/**\n * Listing preview image (AGL-95): the publisher uploads one screenshot per\n * listing, shown on browse cards and the detail page. Storage rules deny\n * client writes, so the mutation runs here — Firebase ID token, listing\n * ownership, image-only, 2MB cap. POST replaces the image and sets\n * `previewImageUrl` on the listing; DELETE removes both.\n *\n * Lives in the plugin, like every other `marketplace/*` operation. It was the\n * one that didn't: it sat in the console app and imported this plugin's\n * `canActAsPublisher` across the `scope:app` → `aglyn:addons` boundary, which\n * failed lint on main. Registered at the same path, so the URL clients call\n * is unchanged — `/api/marketplace/preview-image` reaches the plugin catch-all\n * with exactly the shape it had as a named app route.\n *\n * The email-verified gate (AGL-479) is carried over deliberately: the plugin\n * API dispatcher does NOT apply it centrally, so dropping it here would have\n * quietly widened who can write listing images.\n */\nexport const previewImageHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST' && req.method !== 'DELETE') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const listingId = String(req.body?.listingId ?? '')\n if (!listingId) return res.status(400).json({ error: 'Missing listingId' })\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 try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n if (!decoded.email_verified && !isImpersonationSession(decoded)) {\n return res.status(403).json({\n error: 'Verify your email to continue',\n reason: 'email-unverified',\n })\n }\n const firestore = firebaseAdmin.app().firestore()\n const listingRef = firestore.collection('marketplaceListings').doc(listingId)\n const listingSnapshot = await listingRef.get()\n if (!listingSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown listing' })\n }\n // Listings are org-owned since AGL-652, so `profileId` holds an ORG id.\n // Comparing it to a uid can never be true — it silently 403'd every\n // publisher rather than erroring, which is why this survived the sweep.\n const canEdit = await canActAsPublisher(\n firestore,\n decoded.uid,\n listingSnapshot.get('profileId') as string | undefined,\n )\n if (!canEdit) {\n return res.status(403).json({ error: 'Not your listing' })\n }\n const bucket = firebaseAdmin\n .app()\n .storage()\n .bucket(process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET)\n const file = bucket.file(PREVIEW_PATH(listingId))\n\n if (req.method === 'DELETE') {\n await file.delete().catch(() => undefined)\n await listingRef.set(\n {\n previewImageUrl: firebaseAdmin.firestore.FieldValue.delete(),\n updatedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n return res.status(200).json({ deleted: true })\n }\n\n // DAM-hosted asset (AGL-863): the publisher picked an existing asset from\n // the media library instead of uploading raw bytes. It already lives in\n // our storage/CDN, so point `previewImageUrl` at it directly — no re-upload\n // and no duplicate blob.\n //\n // The comment here used to claim parity with logo/screenshot, \"which\n // already accept any https URL\". That was true and was the bug (AGL-1701):\n // the preview image is the picture on the cross-org BROWSE grid and the\n // `og:image` an unauthenticated unfurler fetches, so \"any https URL\" made\n // a publisher-controlled host the recipient of every browser's IP. Parity\n // is restored in the other direction — this field now takes what the\n // sentence always implied it took, an asset that lives in our library.\n const providedUrl = String(req.body?.url ?? '').trim()\n if (providedUrl) {\n if (providedUrl.length > 2000 || !isFirstPartyMediaSrc(providedUrl)) {\n return res.status(400).json({\n error:\n 'Preview image must come from your media library — pick one with ' +\n 'Browse, or upload the file here',\n })\n }\n await listingRef.set(\n {\n previewImageUrl: providedUrl,\n updatedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n return res.status(200).json({ url: providedUrl })\n }\n\n const contentType = normalizeImageContentType(req.body?.contentType)\n const data = String(req.body?.data ?? '')\n // An ALLOWLIST rather than the `image/` prefix, for the same\n // reason as the DAM routes: the prefix tested a caller-supplied string,\n // so an unrecognised `image/…` label reached storage AND skipped the\n // signature check below, which can only compare bytes against a type it\n // knows.\n if (!isImageUploadContentType(contentType)) {\n return res\n .status(415)\n .json({ error: 'Only image uploads are supported' })\n }\n const buffer = Buffer.from(data, 'base64')\n if (!buffer.length || buffer.length > MAX_FILE_BYTES) {\n return res\n .status(413)\n .json({ error: 'File is empty or too large (2MB)' })\n }\n\n /**\n * STRUCTURAL inspection (AGL-1475). The FIFTH byte ingress, and the one\n * least like the other four.\n *\n * `contentType.startsWith('image/')` above is the entire type gate, and\n * it is taken verbatim from the request body — so `image/png` over a\n * Windows executable satisfied it. Worse than on the DAM routes: the URL\n * this mints points straight at `firebasestorage.googleapis.com` rather\n * than through `serveMediaCdn`, so the stored object never gets that\n * route's `nosniff` and per-type CSP either. And it is the picture on the\n * cross-org BROWSE grid and the `og:image` an unauthenticated unfurler\n * fetches — the same reasoning that hardened the URL branch in AGL-1701,\n * applied at last to the bytes branch.\n *\n * Structure only — not an antivirus scan. See `upload-inspection.ts`.\n */\n const refusal = inspectUploadBytes({\n bytes: buffer,\n contentType,\n fileName: String(req.body?.fileName ?? '') || null,\n })\n if (refusal) {\n return res.status(415).json({ error: refusal.message, code: refusal.code })\n }\n\n /**\n * SVG sanitization. Structural inspection cannot do this: an\n * SVG is text, it has no magic number, and a `<script>` in one is a\n * perfectly well-formed document.\n *\n * It matters more here than on the DAM routes, not less. Those serve\n * through `serveMediaCdn`, which stamps `nosniff` and a `sandbox` CSP on\n * every response and so contains a scripted SVG even if it is stored. The\n * URL this mints points straight at `firebasestorage.googleapis.com` and\n * gets neither header — and it is the `og:image` an unauthenticated\n * unfurler fetches and the picture on the cross-org browse grid, so the\n * audience is every other publisher's browser rather than the uploader's\n * own.\n */\n const bytes = isSvgUploadType(contentType)\n ? sanitizeSvgBuffer(buffer).buffer\n : buffer\n\n const token = randomUUID()\n await file.save(bytes, {\n contentType,\n metadata: { metadata: { firebaseStorageDownloadTokens: token } },\n })\n const url =\n `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/` +\n `${encodeURIComponent(PREVIEW_PATH(listingId))}` +\n `?alt=media&token=${token}`\n\n await listingRef.set(\n {\n previewImageUrl: url,\n updatedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n return res.status(200).json({ url })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Upload failed' })\n }\n}\n\nexport default previewImageHandler\n"],"names":["isFirstPartyMediaSrc","isImageUploadContentType","normalizeImageContentType","isSvgUploadType","sanitizeSvgBuffer","inspectUploadBytes","firebaseAdmin","isImpersonationSession","randomUUID","canActAsPublisher","MAX_FILE_BYTES","PREVIEW_PATH","listingId","previewImageHandler","req","res","method","status","json","error","String","body","authorization","headers","idToken","startsWith","slice","length","undefined","decoded","app","auth","verifyIdToken","email_verified","reason","firestore","listingRef","collection","doc","listingSnapshot","get","exists","canEdit","uid","bucket","storage","process","env","NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET","file","delete","catch","set","previewImageUrl","FieldValue","updatedAt","serverTimestamp","merge","deleted","providedUrl","url","trim","contentType","data","buffer","Buffer","from","refusal","bytes","fileName","message","code","token","save","metadata","firebaseStorageDownloadTokens","name","encodeURIComponent","console"],"mappings":"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"}
@@ -0,0 +1,100 @@
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 { MarketplaceArtifactType } from '../model/marketplace';
18
+ import { type InstalledFrom } from '@aglyn/aglyn/server';
19
+ /**
20
+ * The one writer every install route uses to stamp provenance (AGL-1015).
21
+ *
22
+ * Shared deliberately: each route stamping its own shape is how the marketplace
23
+ * ended up with four different provenance fields and no snapshot, and how
24
+ * AGL-1006 came to depend on a mirror nobody wrote. There is one shape because
25
+ * there is one function producing it.
26
+ */
27
+ /** Content hash of an artifact, stable across field ordering. */
28
+ export declare function artifactContentHash(content: unknown): string;
29
+ /**
30
+ * The same stamp for an artifact that is already content-addressed elsewhere —
31
+ * today only plugins, whose bundle bytes live immutably in the artifact bucket
32
+ * and are integrity-checked at load.
33
+ *
34
+ * Copying those bytes into a Firestore base would be a second, weaker copy of
35
+ * something that already cannot change, so the stamp records the hash and
36
+ * points at the bundle. It goes through this module anyway so there is exactly
37
+ * one place the shape is defined.
38
+ */
39
+ export declare function pinnedProvenance(input: {
40
+ listingId: string;
41
+ listing: {
42
+ profileId?: string;
43
+ };
44
+ version: string;
45
+ sha256: string;
46
+ artifactType: MarketplaceArtifactType;
47
+ }): InstalledFrom;
48
+ /**
49
+ * Has this copy been edited since it was installed? (AGL-1018)
50
+ *
51
+ * The one question an install route must ask before it overwrites: a copy that
52
+ * still matches its base can be replaced freely, and one that does not holds
53
+ * work that replacing would destroy. `false` when there is no base — an install
54
+ * route cannot refuse everything installed before AGL-1015, and the update
55
+ * route is where that case is handled honestly.
56
+ */
57
+ export declare function hasDivergedFromBase(input: {
58
+ firestore: FirebaseFirestore.Firestore;
59
+ sha256: unknown;
60
+ /** The copy's content, in the same shape `recordInstallProvenance` was given. */
61
+ current: unknown;
62
+ }): Promise<boolean>;
63
+ export interface RecordProvenanceInput {
64
+ firestore: FirebaseFirestore.Firestore;
65
+ listingId: string;
66
+ /** The listing document, for the version and publishing org. */
67
+ listing: {
68
+ profileId?: string;
69
+ latestVersion?: unknown;
70
+ };
71
+ /** The version actually installed — not always `listing.latestVersion`. */
72
+ version: string | number | null | undefined;
73
+ artifactType: MarketplaceArtifactType;
74
+ /**
75
+ * The content as installed, exactly as written to the user's document. What
76
+ * goes in here is what "reset to the publisher's version" will restore, so it
77
+ * must be the vendored content and nothing else — no ids, timestamps or
78
+ * quota-derived fields, which differ per install and would read as user edits.
79
+ */
80
+ content: unknown;
81
+ }
82
+ export interface RecordedProvenance {
83
+ /** Write this onto the installed document. */
84
+ installedFrom: InstalledFrom;
85
+ sha256: string;
86
+ /** False when the content was too large to snapshot; the install still proceeds. */
87
+ baseStored: boolean;
88
+ }
89
+ /**
90
+ * Hashes the installed content, stores an immutable base snapshot keyed by that
91
+ * hash, and returns the stamp to write on the installed document.
92
+ *
93
+ * The snapshot is written with `create` and a swallowed failure: the collection
94
+ * is content-addressed, so a document that already exists holds byte-identical
95
+ * content and re-installing the same version must reuse it rather than rewrite
96
+ * it. Nothing here is on the install's critical path — a snapshot that cannot be
97
+ * written leaves `baseStored: false` and an artifact that reports itself as not
98
+ * updatable, which is the honest outcome and better than failing the install.
99
+ */
100
+ export declare function recordInstallProvenance(input: RecordProvenanceInput): Promise<RecordedProvenance>;
@@ -0,0 +1,118 @@
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 { createHash } from "node:crypto";
17
+ import { firebaseAdmin } from "@aglyn/tenant-data-admin";
18
+ import { ARTIFACT_BASE_COLLECTION, ARTIFACT_BASE_MAX_BYTES, stableStringify } from "@aglyn/aglyn/server";
19
+ /**
20
+ * The one writer every install route uses to stamp provenance (AGL-1015).
21
+ *
22
+ * Shared deliberately: each route stamping its own shape is how the marketplace
23
+ * ended up with four different provenance fields and no snapshot, and how
24
+ * AGL-1006 came to depend on a mirror nobody wrote. There is one shape because
25
+ * there is one function producing it.
26
+ */ /** Content hash of an artifact, stable across field ordering. */ export function artifactContentHash(content) {
27
+ return createHash('sha256').update(stableStringify(content), 'utf8').digest('hex');
28
+ }
29
+ /**
30
+ * The same stamp for an artifact that is already content-addressed elsewhere —
31
+ * today only plugins, whose bundle bytes live immutably in the artifact bucket
32
+ * and are integrity-checked at load.
33
+ *
34
+ * Copying those bytes into a Firestore base would be a second, weaker copy of
35
+ * something that already cannot change, so the stamp records the hash and
36
+ * points at the bundle. It goes through this module anyway so there is exactly
37
+ * one place the shape is defined.
38
+ */ export function pinnedProvenance(input) {
39
+ var _input_listing_profileId;
40
+ return {
41
+ listingId: input.listingId,
42
+ version: input.version,
43
+ sha256: input.sha256,
44
+ artifactType: input.artifactType,
45
+ installedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),
46
+ publisherOrgId: (_input_listing_profileId = input.listing.profileId) != null ? _input_listing_profileId : null
47
+ };
48
+ }
49
+ /**
50
+ * Has this copy been edited since it was installed? (AGL-1018)
51
+ *
52
+ * The one question an install route must ask before it overwrites: a copy that
53
+ * still matches its base can be replaced freely, and one that does not holds
54
+ * work that replacing would destroy. `false` when there is no base — an install
55
+ * route cannot refuse everything installed before AGL-1015, and the update
56
+ * route is where that case is handled honestly.
57
+ */ export async function hasDivergedFromBase(input) {
58
+ const sha256 = typeof input.sha256 === 'string' ? input.sha256 : '';
59
+ if (!sha256) return false;
60
+ const snapshot = await input.firestore.collection(ARTIFACT_BASE_COLLECTION).doc(sha256).get().catch(()=>null);
61
+ if (!(snapshot == null ? void 0 : snapshot.exists)) return false;
62
+ return stableStringify(snapshot.get('content')) !== stableStringify(input.current);
63
+ }
64
+ /**
65
+ * Hashes the installed content, stores an immutable base snapshot keyed by that
66
+ * hash, and returns the stamp to write on the installed document.
67
+ *
68
+ * The snapshot is written with `create` and a swallowed failure: the collection
69
+ * is content-addressed, so a document that already exists holds byte-identical
70
+ * content and re-installing the same version must reuse it rather than rewrite
71
+ * it. Nothing here is on the install's critical path — a snapshot that cannot be
72
+ * written leaves `baseStored: false` and an artifact that reports itself as not
73
+ * updatable, which is the honest outcome and better than failing the install.
74
+ */ export async function recordInstallProvenance(input) {
75
+ var _listing_profileId;
76
+ const { firestore, listingId, listing, artifactType, content } = input;
77
+ const version = input.version == null || input.version === '' ? listing.latestVersion == null ? null : String(listing.latestVersion) : String(input.version);
78
+ const serialized = stableStringify(content);
79
+ const sha256 = createHash('sha256').update(serialized, 'utf8').digest('hex');
80
+ const now = firebaseAdmin.firestore.FieldValue.serverTimestamp();
81
+ let baseStored = false;
82
+ if (Buffer.byteLength(serialized, 'utf8') <= ARTIFACT_BASE_MAX_BYTES) {
83
+ const baseRef = firestore.collection(ARTIFACT_BASE_COLLECTION).doc(sha256);
84
+ try {
85
+ var _listing_profileId1;
86
+ await baseRef.create({
87
+ sha256,
88
+ artifactType,
89
+ listingId,
90
+ version,
91
+ publisherOrgId: (_listing_profileId1 = listing.profileId) != null ? _listing_profileId1 : null,
92
+ content,
93
+ createdAt: now
94
+ });
95
+ baseStored = true;
96
+ } catch (unused) {
97
+ var _ref;
98
+ var _this;
99
+ // Already present (the common case on re-install), or unwritable. Either
100
+ // way the base is only usable if it is really there, so confirm.
101
+ baseStored = (_ref = (_this = await baseRef.get().catch(()=>null)) == null ? void 0 : _this.exists) != null ? _ref : false;
102
+ }
103
+ }
104
+ return {
105
+ sha256,
106
+ baseStored,
107
+ installedFrom: {
108
+ listingId,
109
+ version,
110
+ sha256: baseStored ? sha256 : null,
111
+ artifactType,
112
+ installedAt: now,
113
+ publisherOrgId: (_listing_profileId = listing.profileId) != null ? _listing_profileId : null
114
+ }
115
+ };
116
+ }
117
+
118
+ //# sourceMappingURL=provenance.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/provenance.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 { createHash } from 'node:crypto'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport type { MarketplaceArtifactType } from '../model/marketplace'\nimport {\n ARTIFACT_BASE_COLLECTION,\n ARTIFACT_BASE_MAX_BYTES,\n type InstalledFrom,\n stableStringify,\n} from '@aglyn/aglyn/server'\n\n/**\n * The one writer every install route uses to stamp provenance (AGL-1015).\n *\n * Shared deliberately: each route stamping its own shape is how the marketplace\n * ended up with four different provenance fields and no snapshot, and how\n * AGL-1006 came to depend on a mirror nobody wrote. There is one shape because\n * there is one function producing it.\n */\n\n/** Content hash of an artifact, stable across field ordering. */\nexport function artifactContentHash(content: unknown): string {\n return createHash('sha256').update(stableStringify(content), 'utf8').digest('hex')\n}\n\n/**\n * The same stamp for an artifact that is already content-addressed elsewhere —\n * today only plugins, whose bundle bytes live immutably in the artifact bucket\n * and are integrity-checked at load.\n *\n * Copying those bytes into a Firestore base would be a second, weaker copy of\n * something that already cannot change, so the stamp records the hash and\n * points at the bundle. It goes through this module anyway so there is exactly\n * one place the shape is defined.\n */\nexport function pinnedProvenance(input: {\n listingId: string\n listing: { profileId?: string }\n version: string\n sha256: string\n artifactType: MarketplaceArtifactType\n}): InstalledFrom {\n return {\n listingId: input.listingId,\n version: input.version,\n sha256: input.sha256,\n artifactType: input.artifactType,\n installedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n publisherOrgId: input.listing.profileId ?? null,\n }\n}\n\n/**\n * Has this copy been edited since it was installed? (AGL-1018)\n *\n * The one question an install route must ask before it overwrites: a copy that\n * still matches its base can be replaced freely, and one that does not holds\n * work that replacing would destroy. `false` when there is no base — an install\n * route cannot refuse everything installed before AGL-1015, and the update\n * route is where that case is handled honestly.\n */\nexport async function hasDivergedFromBase(input: {\n firestore: FirebaseFirestore.Firestore\n sha256: unknown\n /** The copy's content, in the same shape `recordInstallProvenance` was given. */\n current: unknown\n}): Promise<boolean> {\n const sha256 = typeof input.sha256 === 'string' ? input.sha256 : ''\n if (!sha256) return false\n const snapshot = await input.firestore\n .collection(ARTIFACT_BASE_COLLECTION)\n .doc(sha256)\n .get()\n .catch(() => null)\n if (!snapshot?.exists) return false\n return stableStringify(snapshot.get('content')) !== stableStringify(input.current)\n}\n\nexport interface RecordProvenanceInput {\n firestore: FirebaseFirestore.Firestore\n listingId: string\n /** The listing document, for the version and publishing org. */\n listing: { profileId?: string; latestVersion?: unknown }\n /** The version actually installed — not always `listing.latestVersion`. */\n version: string | number | null | undefined\n artifactType: MarketplaceArtifactType\n /**\n * The content as installed, exactly as written to the user's document. What\n * goes in here is what \"reset to the publisher's version\" will restore, so it\n * must be the vendored content and nothing else — no ids, timestamps or\n * quota-derived fields, which differ per install and would read as user edits.\n */\n content: unknown\n}\n\nexport interface RecordedProvenance {\n /** Write this onto the installed document. */\n installedFrom: InstalledFrom\n sha256: string\n /** False when the content was too large to snapshot; the install still proceeds. */\n baseStored: boolean\n}\n\n/**\n * Hashes the installed content, stores an immutable base snapshot keyed by that\n * hash, and returns the stamp to write on the installed document.\n *\n * The snapshot is written with `create` and a swallowed failure: the collection\n * is content-addressed, so a document that already exists holds byte-identical\n * content and re-installing the same version must reuse it rather than rewrite\n * it. Nothing here is on the install's critical path — a snapshot that cannot be\n * written leaves `baseStored: false` and an artifact that reports itself as not\n * updatable, which is the honest outcome and better than failing the install.\n */\nexport async function recordInstallProvenance(\n input: RecordProvenanceInput,\n): Promise<RecordedProvenance> {\n const { firestore, listingId, listing, artifactType, content } = input\n const version =\n input.version == null || input.version === ''\n ? (listing.latestVersion == null ? null : String(listing.latestVersion))\n : String(input.version)\n const serialized = stableStringify(content)\n const sha256 = createHash('sha256').update(serialized, 'utf8').digest('hex')\n const now = firebaseAdmin.firestore.FieldValue.serverTimestamp()\n\n let baseStored = false\n if (Buffer.byteLength(serialized, 'utf8') <= ARTIFACT_BASE_MAX_BYTES) {\n const baseRef = firestore.collection(ARTIFACT_BASE_COLLECTION).doc(sha256)\n try {\n await baseRef.create({\n sha256,\n artifactType,\n listingId,\n version,\n publisherOrgId: listing.profileId ?? null,\n content,\n createdAt: now,\n })\n baseStored = true\n } catch {\n // Already present (the common case on re-install), or unwritable. Either\n // way the base is only usable if it is really there, so confirm.\n baseStored = (await baseRef.get().catch(() => null))?.exists ?? false\n }\n }\n\n return {\n sha256,\n baseStored,\n installedFrom: {\n listingId,\n version,\n sha256: baseStored ? sha256 : null,\n artifactType,\n installedAt: now,\n publisherOrgId: listing.profileId ?? null,\n },\n }\n}\n"],"names":["createHash","firebaseAdmin","ARTIFACT_BASE_COLLECTION","ARTIFACT_BASE_MAX_BYTES","stableStringify","artifactContentHash","content","update","digest","pinnedProvenance","input","listingId","version","sha256","artifactType","installedAt","firestore","FieldValue","serverTimestamp","publisherOrgId","listing","profileId","hasDivergedFromBase","snapshot","collection","doc","get","catch","exists","current","recordInstallProvenance","latestVersion","String","serialized","now","baseStored","Buffer","byteLength","baseRef","create","createdAt","installedFrom"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,UAAU,QAAQ,cAAa;AACxC,SAASC,aAAa,QAAQ,2BAA0B;AAExD,SACEC,wBAAwB,EACxBC,uBAAuB,EAEvBC,eAAe,QACV,sBAAqB;AAE5B;;;;;;;CAOC,GAED,+DAA+D,GAC/D,OAAO,SAASC,oBAAoBC,OAAgB;IAClD,OAAON,WAAW,UAAUO,MAAM,CAACH,gBAAgBE,UAAU,QAAQE,MAAM,CAAC;AAC9E;AAEA;;;;;;;;;CASC,GACD,OAAO,SAASC,iBAAiBC,KAMhC;QAOmBA;IANlB,OAAO;QACLC,WAAWD,MAAMC,SAAS;QAC1BC,SAASF,MAAME,OAAO;QACtBC,QAAQH,MAAMG,MAAM;QACpBC,cAAcJ,MAAMI,YAAY;QAChCC,aAAad,cAAce,SAAS,CAACC,UAAU,CAACC,eAAe;QAC/DC,cAAc,GAAET,2BAAAA,MAAMU,OAAO,CAACC,SAAS,YAAvBX,2BAA2B;IAC7C;AACF;AAEA;;;;;;;;CAQC,GACD,OAAO,eAAeY,oBAAoBZ,KAKzC;IACC,MAAMG,SAAS,OAAOH,MAAMG,MAAM,KAAK,WAAWH,MAAMG,MAAM,GAAG;IACjE,IAAI,CAACA,QAAQ,OAAO;IACpB,MAAMU,WAAW,MAAMb,MAAMM,SAAS,CACnCQ,UAAU,CAACtB,0BACXuB,GAAG,CAACZ,QACJa,GAAG,GACHC,KAAK,CAAC,IAAM;IACf,IAAI,EAACJ,4BAAAA,SAAUK,MAAM,GAAE,OAAO;IAC9B,OAAOxB,gBAAgBmB,SAASG,GAAG,CAAC,gBAAgBtB,gBAAgBM,MAAMmB,OAAO;AACnF;AA2BA;;;;;;;;;;CAUC,GACD,OAAO,eAAeC,wBACpBpB,KAA4B;QAyCRU;IAvCpB,MAAM,EAAEJ,SAAS,EAAEL,SAAS,EAAES,OAAO,EAAEN,YAAY,EAAER,OAAO,EAAE,GAAGI;IACjE,MAAME,UACJF,MAAME,OAAO,IAAI,QAAQF,MAAME,OAAO,KAAK,KACtCQ,QAAQW,aAAa,IAAI,OAAO,OAAOC,OAAOZ,QAAQW,aAAa,IACpEC,OAAOtB,MAAME,OAAO;IAC1B,MAAMqB,aAAa7B,gBAAgBE;IACnC,MAAMO,SAASb,WAAW,UAAUO,MAAM,CAAC0B,YAAY,QAAQzB,MAAM,CAAC;IACtE,MAAM0B,MAAMjC,cAAce,SAAS,CAACC,UAAU,CAACC,eAAe;IAE9D,IAAIiB,aAAa;IACjB,IAAIC,OAAOC,UAAU,CAACJ,YAAY,WAAW9B,yBAAyB;QACpE,MAAMmC,UAAUtB,UAAUQ,UAAU,CAACtB,0BAA0BuB,GAAG,CAACZ;QACnE,IAAI;gBAMgBO;YALlB,MAAMkB,QAAQC,MAAM,CAAC;gBACnB1B;gBACAC;gBACAH;gBACAC;gBACAO,cAAc,GAAEC,sBAAAA,QAAQC,SAAS,YAAjBD,sBAAqB;gBACrCd;gBACAkC,WAAWN;YACb;YACAC,aAAa;QACf,EAAE,eAAM;;gBAGQ;YAFd,yEAAyE;YACzE,iEAAiE;YACjEA,sBAAc,QAAA,MAAMG,QAAQZ,GAAG,GAAGC,KAAK,CAAC,IAAM,0BAAjC,AAAC,MAAwCC,MAAM,mBAAI;QAClE;IACF;IAEA,OAAO;QACLf;QACAsB;QACAM,eAAe;YACb9B;YACAC;YACAC,QAAQsB,aAAatB,SAAS;YAC9BC;YACAC,aAAamB;YACbf,cAAc,GAAEC,qBAAAA,QAAQC,SAAS,YAAjBD,qBAAqB;QACvC;IACF;AACF"}
@@ -0,0 +1,31 @@
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 PluginApiHandler } from '@aglyn/aglyn/server';
18
+ /**
19
+ * Publishes an org dataset's SCHEMA to the marketplace (AGL-657).
20
+ *
21
+ * Unlike every other publish route this one takes an `orgId` rather than a
22
+ * `hostId`: datasets live at `orgs/{orgId}/datasets/{id}` and are org-shared
23
+ * (AGL-237), so there is no source site to derive the org from. The role gate
24
+ * goes through `resolveOrgPermissions` directly for the same reason.
25
+ *
26
+ * Records NEVER travel — `sanitizeDatasetSchema` reads the model and this
27
+ * handler never touches the `records` subcollection. A dataset's rows are the
28
+ * org's customer data; only the shape is publishable.
29
+ */
30
+ export declare const publishDatasetSchemaHandler: PluginApiHandler;
31
+ export default publishDatasetSchemaHandler;