@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.
- package/LICENSE +201 -0
- package/README.md +7 -0
- package/package.json +51 -0
- package/src/index.d.ts +19 -0
- package/src/index.js +20 -0
- package/src/index.js.map +1 -0
- package/src/lib/components/artifact-update-dialog.component.d.ts +27 -0
- package/src/lib/components/artifact-update-dialog.component.js +244 -0
- package/src/lib/components/artifact-update-dialog.component.js.map +1 -0
- package/src/lib/components/host-plugins-card.component.d.ts +23 -0
- package/src/lib/components/host-plugins-card.component.js +566 -0
- package/src/lib/components/host-plugins-card.component.js.map +1 -0
- package/src/lib/components/listing-content.component.d.ts +57 -0
- package/src/lib/components/listing-content.component.js +1880 -0
- package/src/lib/components/listing-content.component.js.map +1 -0
- package/src/lib/components/listing-image.component.d.ts +46 -0
- package/src/lib/components/listing-image.component.js +38 -0
- package/src/lib/components/listing-image.component.js.map +1 -0
- package/src/lib/components/listing-reviews.component.d.ts +22 -0
- package/src/lib/components/listing-reviews.component.js +419 -0
- package/src/lib/components/listing-reviews.component.js.map +1 -0
- package/src/lib/components/marketplace-browse.component.d.ts +38 -0
- package/src/lib/components/marketplace-browse.component.js +802 -0
- package/src/lib/components/marketplace-browse.component.js.map +1 -0
- package/src/lib/components/plugin-site-set-panel.component.d.ts +24 -0
- package/src/lib/components/plugin-site-set-panel.component.js +137 -0
- package/src/lib/components/plugin-site-set-panel.component.js.map +1 -0
- package/src/lib/components/plugin-site-set.component.d.ts +43 -0
- package/src/lib/components/plugin-site-set.component.js +425 -0
- package/src/lib/components/plugin-site-set.component.js.map +1 -0
- package/src/lib/components/rating-input.component.d.ts +3 -0
- package/src/lib/components/rating-input.component.js +47 -0
- package/src/lib/components/rating-input.component.js.map +1 -0
- package/src/lib/components/report-target.component.d.ts +36 -0
- package/src/lib/components/report-target.component.js +181 -0
- package/src/lib/components/report-target.component.js.map +1 -0
- package/src/lib/components/uninstall-impact-dialog.component.d.ts +40 -0
- package/src/lib/components/uninstall-impact-dialog.component.js +244 -0
- package/src/lib/components/uninstall-impact-dialog.component.js.map +1 -0
- package/src/lib/constants/bundle-common.d.ts +8 -0
- package/src/lib/constants/bundle-common.js +9 -0
- package/src/lib/constants/bundle-common.js.map +1 -0
- package/src/lib/hooks/use-artifact-update.d.ts +41 -0
- package/src/lib/hooks/use-artifact-update.js +169 -0
- package/src/lib/hooks/use-artifact-update.js.map +1 -0
- package/src/lib/hooks/use-marketplace-actions.d.ts +37 -0
- package/src/lib/hooks/use-marketplace-actions.js +523 -0
- package/src/lib/hooks/use-marketplace-actions.js.map +1 -0
- package/src/lib/model/artifact-merge.d.ts +154 -0
- package/src/lib/model/artifact-merge.js +228 -0
- package/src/lib/model/artifact-merge.js.map +1 -0
- package/src/lib/model/index.d.ts +43 -0
- package/src/lib/model/index.js +41 -0
- package/src/lib/model/index.js.map +1 -0
- package/src/lib/model/marketplace-props.d.ts +57 -0
- package/src/lib/model/marketplace-props.js +441 -0
- package/src/lib/model/marketplace-props.js.map +1 -0
- package/src/lib/model/marketplace.d.ts +833 -0
- package/src/lib/model/marketplace.js +929 -0
- package/src/lib/model/marketplace.js.map +1 -0
- package/src/lib/model/rating-field.d.ts +24 -0
- package/src/lib/model/rating-field.js +33 -0
- package/src/lib/model/rating-field.js.map +1 -0
- package/src/lib/plugin.d.ts +33 -0
- package/src/lib/plugin.js +86 -0
- package/src/lib/plugin.js.map +1 -0
- package/src/lib/server/billing-webhook.d.ts +25 -0
- package/src/lib/server/billing-webhook.js +1062 -0
- package/src/lib/server/billing-webhook.js.map +1 -0
- package/src/lib/server/checkout.d.ts +76 -0
- package/src/lib/server/checkout.js +477 -0
- package/src/lib/server/checkout.js.map +1 -0
- package/src/lib/server/connect.d.ts +24 -0
- package/src/lib/server/connect.js +205 -0
- package/src/lib/server/connect.js.map +1 -0
- package/src/lib/server/install-dataset-schema.d.ts +32 -0
- package/src/lib/server/install-dataset-schema.js +255 -0
- package/src/lib/server/install-dataset-schema.js.map +1 -0
- package/src/lib/server/install-email-starter.d.ts +65 -0
- package/src/lib/server/install-email-starter.js +279 -0
- package/src/lib/server/install-email-starter.js.map +1 -0
- package/src/lib/server/install-email-template.d.ts +34 -0
- package/src/lib/server/install-email-template.js +252 -0
- package/src/lib/server/install-email-template.js.map +1 -0
- package/src/lib/server/install-layout.d.ts +29 -0
- package/src/lib/server/install-layout.js +323 -0
- package/src/lib/server/install-layout.js.map +1 -0
- package/src/lib/server/install-pin-counts.d.ts +173 -0
- package/src/lib/server/install-pin-counts.js +248 -0
- package/src/lib/server/install-pin-counts.js.map +1 -0
- package/src/lib/server/install-plugin.d.ts +18 -0
- package/src/lib/server/install-plugin.js +405 -0
- package/src/lib/server/install-plugin.js.map +1 -0
- package/src/lib/server/install-template.d.ts +37 -0
- package/src/lib/server/install-template.js +348 -0
- package/src/lib/server/install-template.js.map +1 -0
- package/src/lib/server/install-theme.d.ts +42 -0
- package/src/lib/server/install-theme.js +313 -0
- package/src/lib/server/install-theme.js.map +1 -0
- package/src/lib/server/install.d.ts +27 -0
- package/src/lib/server/install.js +276 -0
- package/src/lib/server/install.js.map +1 -0
- package/src/lib/server/listing-versions.d.ts +26 -0
- package/src/lib/server/listing-versions.js +378 -0
- package/src/lib/server/listing-versions.js.map +1 -0
- package/src/lib/server/preview-image.d.ts +37 -0
- package/src/lib/server/preview-image.js +216 -0
- package/src/lib/server/preview-image.js.map +1 -0
- package/src/lib/server/provenance.d.ts +100 -0
- package/src/lib/server/provenance.js +118 -0
- package/src/lib/server/provenance.js.map +1 -0
- package/src/lib/server/publish-dataset-schema.d.ts +31 -0
- package/src/lib/server/publish-dataset-schema.js +202 -0
- package/src/lib/server/publish-dataset-schema.js.map +1 -0
- package/src/lib/server/publish-email-starter.d.ts +44 -0
- package/src/lib/server/publish-email-starter.js +231 -0
- package/src/lib/server/publish-email-starter.js.map +1 -0
- package/src/lib/server/publish-email-template.d.ts +33 -0
- package/src/lib/server/publish-email-template.js +211 -0
- package/src/lib/server/publish-email-template.js.map +1 -0
- package/src/lib/server/publish-layout.d.ts +33 -0
- package/src/lib/server/publish-layout.js +243 -0
- package/src/lib/server/publish-layout.js.map +1 -0
- package/src/lib/server/publish-plugin.d.ts +18 -0
- package/src/lib/server/publish-plugin.js +485 -0
- package/src/lib/server/publish-plugin.js.map +1 -0
- package/src/lib/server/publish-preconditions.d.ts +86 -0
- package/src/lib/server/publish-preconditions.js +160 -0
- package/src/lib/server/publish-preconditions.js.map +1 -0
- package/src/lib/server/publish-template.d.ts +27 -0
- package/src/lib/server/publish-template.js +215 -0
- package/src/lib/server/publish-template.js.map +1 -0
- package/src/lib/server/publish-theme.d.ts +38 -0
- package/src/lib/server/publish-theme.js +186 -0
- package/src/lib/server/publish-theme.js.map +1 -0
- package/src/lib/server/publish.d.ts +26 -0
- package/src/lib/server/publish.js +204 -0
- package/src/lib/server/publish.js.map +1 -0
- package/src/lib/server/publisher-profile-save.d.ts +30 -0
- package/src/lib/server/publisher-profile-save.js +226 -0
- package/src/lib/server/publisher-profile-save.js.map +1 -0
- package/src/lib/server/publisher-profile.d.ts +86 -0
- package/src/lib/server/publisher-profile.js +116 -0
- package/src/lib/server/publisher-profile.js.map +1 -0
- package/src/lib/server/purchase-entitlement.d.ts +83 -0
- package/src/lib/server/purchase-entitlement.js +118 -0
- package/src/lib/server/purchase-entitlement.js.map +1 -0
- package/src/lib/server/report.d.ts +32 -0
- package/src/lib/server/report.js +101 -0
- package/src/lib/server/report.js.map +1 -0
- package/src/lib/server/reviews.d.ts +41 -0
- package/src/lib/server/reviews.js +240 -0
- package/src/lib/server/reviews.js.map +1 -0
- package/src/lib/server/update-artifact.d.ts +19 -0
- package/src/lib/server/update-artifact.js +569 -0
- package/src/lib/server/update-artifact.js.map +1 -0
- package/src/lib/server/verification-request.d.ts +31 -0
- package/src/lib/server/verification-request.js +173 -0
- package/src/lib/server/verification-request.js.map +1 -0
- package/src/lib/server/version-stats.d.ts +202 -0
- package/src/lib/server/version-stats.js +305 -0
- package/src/lib/server/version-stats.js.map +1 -0
- package/src/lib/server.d.ts +30 -0
- package/src/lib/server.js +95 -0
- 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;
|