@aglyn/plugins-marketplace 1.2.0 → 1.3.0

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 (73) hide show
  1. package/README.md +1 -1
  2. package/package.json +14 -14
  3. package/src/lib/components/listing-content.component.js +11 -3
  4. package/src/lib/components/listing-content.component.js.map +1 -1
  5. package/src/lib/components/marketplace-pay-panel.component.d.ts +67 -0
  6. package/src/lib/components/marketplace-pay-panel.component.js +228 -0
  7. package/src/lib/components/marketplace-pay-panel.component.js.map +1 -0
  8. package/src/lib/components/publish-artifact-dialog.component.d.ts +1 -1
  9. package/src/lib/components/publish-artifact-dialog.component.js +144 -20
  10. package/src/lib/components/publish-artifact-dialog.component.js.map +1 -1
  11. package/src/lib/components/publish-starter-fields.component.d.ts +35 -0
  12. package/src/lib/components/publish-starter-fields.component.js +176 -0
  13. package/src/lib/components/publish-starter-fields.component.js.map +1 -0
  14. package/src/lib/components/starter-picker-shelf.component.d.ts +2 -2
  15. package/src/lib/components/starter-picker-shelf.component.js +28 -6
  16. package/src/lib/components/starter-picker-shelf.component.js.map +1 -1
  17. package/src/lib/hooks/use-marketplace-actions.d.ts +9 -3
  18. package/src/lib/hooks/use-marketplace-actions.js +47 -89
  19. package/src/lib/hooks/use-marketplace-actions.js.map +1 -1
  20. package/src/lib/hooks/use-marketplace-pay.d.ts +70 -0
  21. package/src/lib/hooks/use-marketplace-pay.js +359 -0
  22. package/src/lib/hooks/use-marketplace-pay.js.map +1 -0
  23. package/src/lib/hooks/use-starter-publish-facts.d.ts +25 -0
  24. package/src/lib/hooks/use-starter-publish-facts.js +116 -0
  25. package/src/lib/hooks/use-starter-publish-facts.js.map +1 -0
  26. package/src/lib/hooks/use-starter-purchase.d.ts +17 -22
  27. package/src/lib/hooks/use-starter-purchase.js +26 -73
  28. package/src/lib/hooks/use-starter-purchase.js.map +1 -1
  29. package/src/lib/model/marketplace-invoice.d.ts +52 -0
  30. package/src/lib/model/marketplace-invoice.js +75 -0
  31. package/src/lib/model/marketplace-invoice.js.map +1 -0
  32. package/src/lib/model/marketplace-pay.d.ts +42 -0
  33. package/src/lib/model/marketplace-pay.js +33 -0
  34. package/src/lib/model/marketplace-pay.js.map +1 -0
  35. package/src/lib/model/platform-seller.d.ts +37 -0
  36. package/src/lib/model/platform-seller.js +36 -0
  37. package/src/lib/model/platform-seller.js.map +1 -0
  38. package/src/lib/model/starter-publish.d.ts +159 -0
  39. package/src/lib/model/starter-publish.js +189 -0
  40. package/src/lib/model/starter-publish.js.map +1 -0
  41. package/src/lib/model/starter-query.d.ts +0 -8
  42. package/src/lib/model/starter-query.js +0 -10
  43. package/src/lib/model/starter-query.js.map +1 -1
  44. package/src/lib/server/billing-webhook.d.ts +6 -0
  45. package/src/lib/server/billing-webhook.js +269 -211
  46. package/src/lib/server/billing-webhook.js.map +1 -1
  47. package/src/lib/server/checkout.d.ts +2 -48
  48. package/src/lib/server/checkout.js +21 -241
  49. package/src/lib/server/checkout.js.map +1 -1
  50. package/src/lib/server/pay.d.ts +18 -0
  51. package/src/lib/server/pay.js +540 -0
  52. package/src/lib/server/pay.js.map +1 -0
  53. package/src/lib/server/platform-org.d.ts +23 -0
  54. package/src/lib/server/platform-org.js +27 -0
  55. package/src/lib/server/platform-org.js.map +1 -0
  56. package/src/lib/server/publish-preconditions.js +4 -1
  57. package/src/lib/server/publish-preconditions.js.map +1 -1
  58. package/src/lib/server/publish-starter.js +5 -2
  59. package/src/lib/server/publish-starter.js.map +1 -1
  60. package/src/lib/server/publisher-profile.d.ts +5 -0
  61. package/src/lib/server/publisher-profile.js +20 -1
  62. package/src/lib/server/publisher-profile.js.map +1 -1
  63. package/src/lib/server/publisher-standing.d.ts +26 -0
  64. package/src/lib/server/publisher-standing.js +66 -0
  65. package/src/lib/server/publisher-standing.js.map +1 -0
  66. package/src/lib/server/sale.d.ts +119 -0
  67. package/src/lib/server/sale.js +304 -0
  68. package/src/lib/server/sale.js.map +1 -0
  69. package/src/lib/server/starter-publish-facts.d.ts +36 -0
  70. package/src/lib/server/starter-publish-facts.js +135 -0
  71. package/src/lib/server/starter-publish-facts.js.map +1 -0
  72. package/src/lib/server.js +8 -0
  73. package/src/lib/server.js.map +1 -1
@@ -0,0 +1,119 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /**
18
+ * THE TAX CLASSIFICATION OF A MARKETPLACE SALE (AGL-1553).
19
+ *
20
+ * "Software as a service (SaaS) — business use". Sent explicitly on the line
21
+ * item below, because this session builds its product ad hoc from
22
+ * `price_data` and an ad-hoc product with no `tax_code` is classified by the
23
+ * ACCOUNT-LEVEL preset instead — a value set by hand in the Stripe Dashboard
24
+ * for Aglyn's own subscription products (AGL-1537/1877), which happens to be
25
+ * this same string.
26
+ *
27
+ * So this is not a change of tax position, and deliberately not: it is the
28
+ * position Aglyn already collects on, already verified, and already states in
29
+ * a signed filing. The pending Texas private letter ruling request proposes at
30
+ * Part 6.1 that plugin charges are DATA PROCESSING SERVICES under Tax Code
31
+ * § 151.0035, of which § 151.351 exempts 20%, so tax is due on 80% of the
32
+ * charge; Part 4.5 ¶ 4 represents to the Comptroller that "the payment
33
+ * processor's tax classification applied to these charges is the
34
+ * software-as-a-service category" and that the 80% measure was confirmed by
35
+ * computation. AGL-1817 ran that computation against the live account: $6.60
36
+ * of tax on a $100.00 charge at an 8.25% Texas address — 8.25% x $80.
37
+ *
38
+ * What was actually broken is that none of that was expressed HERE. The
39
+ * classification of every marketplace sale hung on a Dashboard field owned by
40
+ * a different product line. Re-preset the account for storefront goods or
41
+ * bookings and these sales silently re-classify, moving the Texas base off the
42
+ * 80% the filing describes — with no diff to review, no failing test, and no
43
+ * symptom short of reconciling a return. Naming it here is what makes the
44
+ * filed representation survive a Dashboard edit.
45
+ *
46
+ * NOT merged with `PLATFORM_TAX_CODE` in `tools/scripts/lib/stripe-tax-code.mjs`,
47
+ * which stamps Aglyn's OWN products with the same string today. They answer
48
+ * different questions — Aglyn's first-party subscription vs. a third party's
49
+ * sale through Aglyn's marketplace — and the ruling request itself flags at
50
+ * Part 8 ¶ 8 that the Comptroller could characterize the two differently. One
51
+ * shared constant would make a divergence the filing anticipates impossible to
52
+ * express without touching the other business.
53
+ *
54
+ * DO NOT change this value to a downloadable-software code (`txcd_10202003`,
55
+ * `txcd_10203001`) on the reasoning that a plugin is "downloaded". That is the
56
+ * OPPOSING characterization — tangible personal property under Rule 3.308,
57
+ * taxable in full — which Part 6.1 argues against and which the filing already
58
+ * states against itself, in full, at Part 8 ¶¶ 1-2. Adopting
59
+ * it here would raise the tax charged to every buyer by a quarter of the base
60
+ * and make Part 4.5 ¶ 4 false. It is the Comptroller's call, and if the ruling
61
+ * goes that way the change is a one-line edit at this constant plus a
62
+ * prospective conformance — which is exactly what naming it here buys.
63
+ */
64
+ export declare const MARKETPLACE_TAX_CODE = "txcd_10103001";
65
+ /** Who is buying: the person, the workspace the licence is for, and what they may do in it. */
66
+ export interface SaleBuyer {
67
+ uid: string;
68
+ orgId: string;
69
+ permissions: {
70
+ createHosts?: boolean;
71
+ installPlugins?: boolean;
72
+ };
73
+ }
74
+ /** One sale, decided: everything the payment doors need and nothing they chose. */
75
+ export interface MarketplaceSale {
76
+ listing: Record<string, any>;
77
+ /** A site starter is bought to create a site with, everything else to install. */
78
+ siteStarter: boolean;
79
+ sellerOrgId: string;
80
+ feePercent: number;
81
+ amountCents: number;
82
+ feeCents: number;
83
+ /** What the seller receives: 0 when the platform's own org is the seller. */
84
+ transferCents: number;
85
+ /** The seller's Connect account; absent when {@link platformSale}. */
86
+ accountId: string | undefined;
87
+ /** The platform's own org is selling, so no transfer is made at all. */
88
+ platformSale: boolean;
89
+ }
90
+ /** The answer a door sends instead of taking money: the status and body, decided together. */
91
+ export interface SaleRefusal {
92
+ status: number;
93
+ body: Record<string, unknown>;
94
+ }
95
+ export type SaleResolution = {
96
+ sale: MarketplaceSale;
97
+ } | {
98
+ refusal: SaleRefusal;
99
+ };
100
+ /**
101
+ * EVERYTHING A MARKETPLACE SALE DECIDES BEFORE STRIPE (AGL-4049).
102
+ *
103
+ * One value, for both doors that take money for a listing: the hosted
104
+ * checkout (`checkout.ts`) and the in-app payment (`pay.ts`). A second copy of
105
+ * these gates is how a door comes to sell something the other refuses, and
106
+ * every refusal below has already cost a real sale or a real refund to learn.
107
+ *
108
+ * The caller has settled WHO is buying, through the person's own membership;
109
+ * this settles whether that workspace may buy this listing, from this seller,
110
+ * today, and at what split.
111
+ *
112
+ * Deliberately before the door's idempotency claim, so none of these burns the
113
+ * key: each is a state somebody fixes before pressing the same button again.
114
+ */
115
+ export declare function resolveSale(input: {
116
+ firestore: FirebaseFirestore.Firestore;
117
+ listingId: string;
118
+ buyer: SaleBuyer;
119
+ }): Promise<SaleResolution>;
@@ -0,0 +1,304 @@
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 { merchantAccountIsReady } from "@aglyn/tenant-data-admin/server/payment-provider";
17
+ import { checkEntitlement, isListingWideRevocation, resolveMarketplaceFeePct } from "@aglyn/aglyn/server";
18
+ import { permissionRefusalText } from "@aglyn/aglyn/app-utils/org-permissions";
19
+ import { SITE_STARTER_ARTIFACT_TYPE } from "@aglyn/aglyn/app-utils/site-starter";
20
+ import { isListingBrowsable, isPrivateListing, listingArtifactType } from "../model/marketplace.js";
21
+ import { sellsAsPlatform } from "../model/platform-seller.js";
22
+ import { readPlatformOrgId } from "./platform-org.js";
23
+ import { canActAsPublisher, resolvePublisherProfile } from "./publisher-profile.js";
24
+ import { hasLivePurchase } from "./purchase-entitlement.js";
25
+ import { isOrgMember } from "./sale-risk.js";
26
+ /**
27
+ * THE TAX CLASSIFICATION OF A MARKETPLACE SALE (AGL-1553).
28
+ *
29
+ * "Software as a service (SaaS) — business use". Sent explicitly on the line
30
+ * item below, because this session builds its product ad hoc from
31
+ * `price_data` and an ad-hoc product with no `tax_code` is classified by the
32
+ * ACCOUNT-LEVEL preset instead — a value set by hand in the Stripe Dashboard
33
+ * for Aglyn's own subscription products (AGL-1537/1877), which happens to be
34
+ * this same string.
35
+ *
36
+ * So this is not a change of tax position, and deliberately not: it is the
37
+ * position Aglyn already collects on, already verified, and already states in
38
+ * a signed filing. The pending Texas private letter ruling request proposes at
39
+ * Part 6.1 that plugin charges are DATA PROCESSING SERVICES under Tax Code
40
+ * § 151.0035, of which § 151.351 exempts 20%, so tax is due on 80% of the
41
+ * charge; Part 4.5 ¶ 4 represents to the Comptroller that "the payment
42
+ * processor's tax classification applied to these charges is the
43
+ * software-as-a-service category" and that the 80% measure was confirmed by
44
+ * computation. AGL-1817 ran that computation against the live account: $6.60
45
+ * of tax on a $100.00 charge at an 8.25% Texas address — 8.25% x $80.
46
+ *
47
+ * What was actually broken is that none of that was expressed HERE. The
48
+ * classification of every marketplace sale hung on a Dashboard field owned by
49
+ * a different product line. Re-preset the account for storefront goods or
50
+ * bookings and these sales silently re-classify, moving the Texas base off the
51
+ * 80% the filing describes — with no diff to review, no failing test, and no
52
+ * symptom short of reconciling a return. Naming it here is what makes the
53
+ * filed representation survive a Dashboard edit.
54
+ *
55
+ * NOT merged with `PLATFORM_TAX_CODE` in `tools/scripts/lib/stripe-tax-code.mjs`,
56
+ * which stamps Aglyn's OWN products with the same string today. They answer
57
+ * different questions — Aglyn's first-party subscription vs. a third party's
58
+ * sale through Aglyn's marketplace — and the ruling request itself flags at
59
+ * Part 8 ¶ 8 that the Comptroller could characterize the two differently. One
60
+ * shared constant would make a divergence the filing anticipates impossible to
61
+ * express without touching the other business.
62
+ *
63
+ * DO NOT change this value to a downloadable-software code (`txcd_10202003`,
64
+ * `txcd_10203001`) on the reasoning that a plugin is "downloaded". That is the
65
+ * OPPOSING characterization — tangible personal property under Rule 3.308,
66
+ * taxable in full — which Part 6.1 argues against and which the filing already
67
+ * states against itself, in full, at Part 8 ¶¶ 1-2. Adopting
68
+ * it here would raise the tax charged to every buyer by a quarter of the base
69
+ * and make Part 4.5 ¶ 4 false. It is the Comptroller's call, and if the ruling
70
+ * goes that way the change is a one-line edit at this constant plus a
71
+ * prospective conformance — which is exactly what naming it here buys.
72
+ */ export const MARKETPLACE_TAX_CODE = 'txcd_10103001';
73
+ const refuse = (status, body)=>({
74
+ refusal: {
75
+ status,
76
+ body
77
+ }
78
+ });
79
+ /**
80
+ * EVERYTHING A MARKETPLACE SALE DECIDES BEFORE STRIPE (AGL-4049).
81
+ *
82
+ * One value, for both doors that take money for a listing: the hosted
83
+ * checkout (`checkout.ts`) and the in-app payment (`pay.ts`). A second copy of
84
+ * these gates is how a door comes to sell something the other refuses, and
85
+ * every refusal below has already cost a real sale or a real refund to learn.
86
+ *
87
+ * The caller has settled WHO is buying, through the person's own membership;
88
+ * this settles whether that workspace may buy this listing, from this seller,
89
+ * today, and at what split.
90
+ *
91
+ * Deliberately before the door's idempotency claim, so none of these burns the
92
+ * key: each is a state somebody fixes before pressing the same button again.
93
+ */ export async function resolveSale(input) {
94
+ var _listing_priceUsd, _listing_profileId, _sellerOrgDoc_data;
95
+ const { firestore, listingId, buyer } = input;
96
+ const listingSnapshot = await firestore.collection('marketplaceListings').doc(listingId).get();
97
+ const listing = listingSnapshot.data();
98
+ if (!listing || listing.deletedAt) {
99
+ return refuse(404, {
100
+ error: 'Unknown listing'
101
+ });
102
+ }
103
+ // A site starter is bought to create a site with (AGL-4049), so buying
104
+ // one takes the permission that creates sites, as picking one in Create
105
+ // site does; everything else is bought to install, as before. Asked of
106
+ // the listing, never of the request, and before anything else about the
107
+ // sale is read.
108
+ const siteStarter = listingArtifactType(listing) === SITE_STARTER_ARTIFACT_TYPE;
109
+ if (siteStarter ? !buyer.permissions.createHosts : !buyer.permissions.installPlugins) {
110
+ return refuse(403, {
111
+ error: siteStarter ? permissionRefusalText('hosts.create') : 'Your organization role does not allow installing from the marketplace'
112
+ });
113
+ }
114
+ // Review gating holds on the PAID path too (AGL-1543): browse hides
115
+ // rejected/submitted/in_review listings, so a direct checkout call must
116
+ // not be a side door that takes money for one. Absent = legacy = listed
117
+ // (AGL-432), and the same 404 as an unknown listing — purchasability is
118
+ // not a probe for review state.
119
+ const reviewStatus = listing.reviewStatus;
120
+ if (reviewStatus && reviewStatus !== 'listed' && reviewStatus !== 'verified') {
121
+ return refuse(404, {
122
+ error: 'Unknown listing'
123
+ });
124
+ }
125
+ // A TAKEN-DOWN or PRIVATE listing is not on sale (AGL-2288).
126
+ //
127
+ // The check above is a hand-rolled subset of `isListingBrowsable`, and
128
+ // the two lines it dropped are the two that matter here: `hiddenAt` and
129
+ // `isPrivateListing`. A staff takedown does NOT set a blocking
130
+ // `reviewStatus` — `plugin-reviews` demotes a verified listing to
131
+ // `VERIFIED_STRIPPED_STATUS`, which is literally `'listed'` — so after a
132
+ // takedown this route read `reviewStatus: 'listed'` and sold the listing
133
+ // in full, while `install-plugin` refused the very same listing at
134
+ // `hiddenAt` with a 404. The buyer was charged for something the platform
135
+ // had already decided nobody may install, and the only way out was a
136
+ // refund — which is Aglyn's Stripe fee, Aglyn's support time, and (until
137
+ // the partial-refund fix) a real hole in the split.
138
+ //
139
+ // Both tests are kept rather than replaced by the shared predicate: for a
140
+ // NON-plugin artifact `isListingBrowsable` returns true whatever the
141
+ // review status says, because pre-publication review is plugin-only. This
142
+ // route has always been stricter than that, and a sale is the wrong place
143
+ // to loosen a gate.
144
+ //
145
+ // No owner exemption, unlike the install routes: `canActAsPublisher`
146
+ // below already refuses a publisher buying their own listing, so the
147
+ // buyer here is never the owner.
148
+ if (!isListingBrowsable(listing) || isPrivateListing(listing)) {
149
+ return refuse(404, {
150
+ error: 'Unknown listing'
151
+ });
152
+ }
153
+ const priceUsd = Number((_listing_priceUsd = listing.priceUsd) != null ? _listing_priceUsd : 0);
154
+ if (!(priceUsd > 0)) {
155
+ return refuse(400, {
156
+ error: 'Listing is free'
157
+ });
158
+ }
159
+ // Publishing is org-owned (AGL-652): `profileId` IS the publisher org id.
160
+ const sellerOrgId = String((_listing_profileId = listing.profileId) != null ? _listing_profileId : '');
161
+ // ANY member of the publishing workspace, and any purchase FOR it
162
+ // (AGL-3365). `canActAsPublisher` asks only for an owner or admin, so an
163
+ // editor of the publisher — or an owner buying "for" the publisher's own
164
+ // workspace through a role that can install — passed it, and a publisher
165
+ // could pay itself: the stolen-card cash-out the payout exists to make
166
+ // worth doing. A workspace one step removed is the sale screen's to flag.
167
+ if (buyer.orgId === sellerOrgId || await canActAsPublisher(firestore, buyer.uid, sellerOrgId) || await isOrgMember(firestore, sellerOrgId, buyer.uid)) {
168
+ return refuse(400, {
169
+ error: 'Your organization published this listing'
170
+ });
171
+ }
172
+ // You cannot buy the same component twice (AGL-1697).
173
+ //
174
+ // This route validated review status, self-purchase and the seller's
175
+ // entitlements, and then never asked the one question the buyer cares
176
+ // about: do they already own it. A marketplace listing is a buy-once good —
177
+ // a second purchase buys nothing, since `requirePurchase` is satisfied by
178
+ // the first — so a buyer who clicked Buy again from a stale tab, or came
179
+ // back through a bookmarked checkout, was simply charged twice for it.
180
+ //
181
+ // Deliberately the SAME predicate the seven install routes gate on
182
+ // (AGL-1546/1699) rather than a second query beside it: "has this buyer
183
+ // paid for this listing" must have one answer, or the marketplace can take
184
+ // money for something it will not install, or refuse to sell something it
185
+ // will not install either. That predicate reads a fully REFUNDED purchase
186
+ // as absent, which is exactly right here too — a refunded buyer must be
187
+ // able to buy again, and a refunded row sitting beside a live one does not
188
+ // veto it.
189
+ //
190
+ // 409 rather than 400: nothing about the request is malformed, the buyer
191
+ // is just asking for a state that already holds.
192
+ //
193
+ // KEYED ON THE ORGANIZATION SINCE AGL-2331, and that is what turns this
194
+ // guard from a blocked sale back into a real one. A licence belongs to a
195
+ // workspace, so an agency developer who bought a component for one client
196
+ // must be able to buy it again for the next — the person-keyed version of
197
+ // this check answered `409 already_purchased` to exactly the second sale
198
+ // we want. It still refuses the case it was written for: the same
199
+ // workspace buying the same listing twice.
200
+ //
201
+ // A pre-AGL-2331 purchase still refuses, through the legacy grant inside
202
+ // the shared predicate: that buyer can already install this listing
203
+ // anywhere, so selling them a second copy would take money for nothing.
204
+ if (await hasLivePurchase({
205
+ firestore,
206
+ buyerUid: buyer.uid,
207
+ buyerOrgId: buyer.orgId,
208
+ listingId
209
+ })) {
210
+ return refuse(409, {
211
+ error: 'This organization already owns this listing — install it from ' + 'your library.',
212
+ code: 'already_purchased'
213
+ });
214
+ }
215
+ // The kill switch, on the SELL side too (AGL-2288).
216
+ //
217
+ // `revocations/{listingId}` with `versions: 'all'` is the listing-wide
218
+ // kill: `install-plugin` 409s on it through this same predicate, and the
219
+ // tenant runtime renders the disabled placeholder for anything already
220
+ // pinned. Selling into that state takes money for an artifact the
221
+ // platform has switched off.
222
+ //
223
+ // Only `'all'` is consulted, deliberately. A per-version revocation
224
+ // (AGL-1085) stops one build while other approved versions stay
225
+ // installable, and refusing the sale for it would turn a targeted control
226
+ // into a de-listing. Whether the newest approved version happens to be
227
+ // the revoked one is a question `install-plugin` answers with live data;
228
+ // duplicating that resolution here would be a second copy of it.
229
+ //
230
+ // One extra read on a path that already makes several Firestore and
231
+ // Stripe round trips, and it is the cheap half of the takedown check
232
+ // above: a hand-written revocation doc — which the rules permit any staff
233
+ // client to write — is caught here even when `hiddenAt` was never set.
234
+ const revocation = (await firestore.collection('revocations').doc(listingId).get()).data();
235
+ if (isListingWideRevocation(revocation)) {
236
+ return refuse(404, {
237
+ error: 'Unknown listing'
238
+ });
239
+ }
240
+ const publisher = await resolvePublisherProfile(firestore, sellerOrgId);
241
+ const accountId = publisher == null ? void 0 : publisher.stripeAccountId;
242
+ // The platform's cut comes from ENTITLEMENTS (AGL-1543), never the raw
243
+ // `plan` field: `resolveMarketplaceFeePct` prices a dead subscription
244
+ // as free-plan (30%) while the stale field still says pro, and honors
245
+ // a negotiated per-org `marketplaceFeePct` override. Same read gates
246
+ // SALE time on `marketplaceSelling` — publish already refuses without
247
+ // it, and a publisher who downgrades must stop selling, not merely
248
+ // stop publishing. Resolved per request; entitlements are never cached.
249
+ const sellerOrgDoc = await firestore.collection('orgs').doc(sellerOrgId).get();
250
+ const sellerOrg = (_sellerOrgDoc_data = sellerOrgDoc.data()) != null ? _sellerOrgDoc_data : {};
251
+ const feePercent = resolveMarketplaceFeePct(sellerOrg);
252
+ // THE PLATFORM'S OWN ORG, AT THE STAFF WAIVER (AGL-4049). Nobody to pay, so
253
+ // no account to be ready and no transfer to make. Asked here, ahead of the
254
+ // payout gate, which is the only refusal it changes: a lock and the plan
255
+ // below hold for it as for anyone.
256
+ const platformSale = sellsAsPlatform({
257
+ sellerOrgId,
258
+ feePercent,
259
+ platformOrgId: readPlatformOrgId()
260
+ });
261
+ if (!platformSale && !merchantAccountIsReady({
262
+ accountId,
263
+ chargesEnabled: publisher == null ? void 0 : publisher.stripeChargesEnabled,
264
+ accountLivemode: publisher == null ? void 0 : publisher.stripeAccountLivemode
265
+ }, {
266
+ subject: `marketplace publisher org ${sellerOrgId}`
267
+ })) {
268
+ return refuse(409, {
269
+ error: 'The publisher has not enabled payouts yet'
270
+ });
271
+ }
272
+ // A LOCKED publisher sells nothing (AGL-3365). Any lock, not only a
273
+ // security one: a sale to a workspace staff have stopped is money moving
274
+ // to it, and the lockdown's payout pause cannot reach a share that is
275
+ // never charged. The same answer as a taken-down listing — a lock is not
276
+ // a buyer's business.
277
+ if (sellerOrg.suspendedAt != null) {
278
+ return refuse(404, {
279
+ error: 'Unknown listing'
280
+ });
281
+ }
282
+ if (!checkEntitlement(sellerOrg, 'marketplaceSelling')) {
283
+ return refuse(409, {
284
+ error: "The publisher's current plan does not include marketplace selling"
285
+ });
286
+ }
287
+ const amountCents = Math.round(priceUsd * 100);
288
+ const feeCents = Math.round(amountCents * feePercent / 100);
289
+ return {
290
+ sale: {
291
+ listing,
292
+ siteStarter,
293
+ sellerOrgId,
294
+ feePercent,
295
+ amountCents,
296
+ feeCents,
297
+ transferCents: platformSale ? 0 : amountCents - feeCents,
298
+ accountId: platformSale ? undefined : accountId,
299
+ platformSale
300
+ }
301
+ };
302
+ }
303
+
304
+ //# sourceMappingURL=sale.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/sale.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 { merchantAccountIsReady } from '@aglyn/tenant-data-admin/server/payment-provider'\nimport {\n checkEntitlement,\n isListingWideRevocation,\n resolveMarketplaceFeePct,\n type PluginRevocation,\n} from '@aglyn/aglyn/server'\nimport { permissionRefusalText } from '@aglyn/aglyn/app-utils/org-permissions'\nimport { SITE_STARTER_ARTIFACT_TYPE } from '@aglyn/aglyn/app-utils/site-starter'\nimport { isListingBrowsable, isPrivateListing, listingArtifactType } from '../model/marketplace'\nimport { sellsAsPlatform } from '../model/platform-seller'\nimport { readPlatformOrgId } from './platform-org'\nimport { canActAsPublisher, resolvePublisherProfile } from './publisher-profile'\nimport { hasLivePurchase } from './purchase-entitlement'\nimport { isOrgMember } from './sale-risk'\n\n/**\n * THE TAX CLASSIFICATION OF A MARKETPLACE SALE (AGL-1553).\n *\n * \"Software as a service (SaaS) — business use\". Sent explicitly on the line\n * item below, because this session builds its product ad hoc from\n * `price_data` and an ad-hoc product with no `tax_code` is classified by the\n * ACCOUNT-LEVEL preset instead — a value set by hand in the Stripe Dashboard\n * for Aglyn's own subscription products (AGL-1537/1877), which happens to be\n * this same string.\n *\n * So this is not a change of tax position, and deliberately not: it is the\n * position Aglyn already collects on, already verified, and already states in\n * a signed filing. The pending Texas private letter ruling request proposes at\n * Part 6.1 that plugin charges are DATA PROCESSING SERVICES under Tax Code\n * § 151.0035, of which § 151.351 exempts 20%, so tax is due on 80% of the\n * charge; Part 4.5 ¶ 4 represents to the Comptroller that \"the payment\n * processor's tax classification applied to these charges is the\n * software-as-a-service category\" and that the 80% measure was confirmed by\n * computation. AGL-1817 ran that computation against the live account: $6.60\n * of tax on a $100.00 charge at an 8.25% Texas address — 8.25% x $80.\n *\n * What was actually broken is that none of that was expressed HERE. The\n * classification of every marketplace sale hung on a Dashboard field owned by\n * a different product line. Re-preset the account for storefront goods or\n * bookings and these sales silently re-classify, moving the Texas base off the\n * 80% the filing describes — with no diff to review, no failing test, and no\n * symptom short of reconciling a return. Naming it here is what makes the\n * filed representation survive a Dashboard edit.\n *\n * NOT merged with `PLATFORM_TAX_CODE` in `tools/scripts/lib/stripe-tax-code.mjs`,\n * which stamps Aglyn's OWN products with the same string today. They answer\n * different questions — Aglyn's first-party subscription vs. a third party's\n * sale through Aglyn's marketplace — and the ruling request itself flags at\n * Part 8 ¶ 8 that the Comptroller could characterize the two differently. One\n * shared constant would make a divergence the filing anticipates impossible to\n * express without touching the other business.\n *\n * DO NOT change this value to a downloadable-software code (`txcd_10202003`,\n * `txcd_10203001`) on the reasoning that a plugin is \"downloaded\". That is the\n * OPPOSING characterization — tangible personal property under Rule 3.308,\n * taxable in full — which Part 6.1 argues against and which the filing already\n * states against itself, in full, at Part 8 ¶¶ 1-2. Adopting\n * it here would raise the tax charged to every buyer by a quarter of the base\n * and make Part 4.5 ¶ 4 false. It is the Comptroller's call, and if the ruling\n * goes that way the change is a one-line edit at this constant plus a\n * prospective conformance — which is exactly what naming it here buys.\n */\nexport const MARKETPLACE_TAX_CODE = 'txcd_10103001'\n\n/** Who is buying: the person, the workspace the licence is for, and what they may do in it. */\nexport interface SaleBuyer {\n uid: string\n orgId: string\n permissions: { createHosts?: boolean; installPlugins?: boolean }\n}\n\n/** One sale, decided: everything the payment doors need and nothing they chose. */\nexport interface MarketplaceSale {\n listing: Record<string, any>\n /** A site starter is bought to create a site with, everything else to install. */\n siteStarter: boolean\n sellerOrgId: string\n feePercent: number\n amountCents: number\n feeCents: number\n /** What the seller receives: 0 when the platform's own org is the seller. */\n transferCents: number\n /** The seller's Connect account; absent when {@link platformSale}. */\n accountId: string | undefined\n /** The platform's own org is selling, so no transfer is made at all. */\n platformSale: boolean\n}\n\n/** The answer a door sends instead of taking money: the status and body, decided together. */\nexport interface SaleRefusal {\n status: number\n body: Record<string, unknown>\n}\n\nexport type SaleResolution = { sale: MarketplaceSale } | { refusal: SaleRefusal }\n\nconst refuse = (status: number, body: Record<string, unknown>): SaleResolution => ({\n refusal: { status, body },\n})\n\n/**\n * EVERYTHING A MARKETPLACE SALE DECIDES BEFORE STRIPE (AGL-4049).\n *\n * One value, for both doors that take money for a listing: the hosted\n * checkout (`checkout.ts`) and the in-app payment (`pay.ts`). A second copy of\n * these gates is how a door comes to sell something the other refuses, and\n * every refusal below has already cost a real sale or a real refund to learn.\n *\n * The caller has settled WHO is buying, through the person's own membership;\n * this settles whether that workspace may buy this listing, from this seller,\n * today, and at what split.\n *\n * Deliberately before the door's idempotency claim, so none of these burns the\n * key: each is a state somebody fixes before pressing the same button again.\n */\nexport async function resolveSale(input: {\n firestore: FirebaseFirestore.Firestore\n listingId: string\n buyer: SaleBuyer\n}): Promise<SaleResolution> {\n const { firestore, listingId, buyer } = input\n const listingSnapshot = await firestore\n .collection('marketplaceListings')\n .doc(listingId)\n .get()\n const listing = listingSnapshot.data() as any\n if (!listing || listing.deletedAt) {\n return refuse(404, { error: 'Unknown listing' })\n }\n // A site starter is bought to create a site with (AGL-4049), so buying\n // one takes the permission that creates sites, as picking one in Create\n // site does; everything else is bought to install, as before. Asked of\n // the listing, never of the request, and before anything else about the\n // sale is read.\n const siteStarter = listingArtifactType(listing) === SITE_STARTER_ARTIFACT_TYPE\n if (siteStarter ? !buyer.permissions.createHosts : !buyer.permissions.installPlugins) {\n return refuse(403, {\n error: siteStarter\n ? permissionRefusalText('hosts.create')\n : 'Your organization role does not allow installing from the marketplace',\n })\n }\n // Review gating holds on the PAID path too (AGL-1543): browse hides\n // rejected/submitted/in_review listings, so a direct checkout call must\n // not be a side door that takes money for one. Absent = legacy = listed\n // (AGL-432), and the same 404 as an unknown listing — purchasability is\n // not a probe for review state.\n const reviewStatus = listing.reviewStatus as string | undefined\n if (reviewStatus && reviewStatus !== 'listed' && reviewStatus !== 'verified') {\n return refuse(404, { error: 'Unknown listing' })\n }\n // A TAKEN-DOWN or PRIVATE listing is not on sale (AGL-2288).\n //\n // The check above is a hand-rolled subset of `isListingBrowsable`, and\n // the two lines it dropped are the two that matter here: `hiddenAt` and\n // `isPrivateListing`. A staff takedown does NOT set a blocking\n // `reviewStatus` — `plugin-reviews` demotes a verified listing to\n // `VERIFIED_STRIPPED_STATUS`, which is literally `'listed'` — so after a\n // takedown this route read `reviewStatus: 'listed'` and sold the listing\n // in full, while `install-plugin` refused the very same listing at\n // `hiddenAt` with a 404. The buyer was charged for something the platform\n // had already decided nobody may install, and the only way out was a\n // refund — which is Aglyn's Stripe fee, Aglyn's support time, and (until\n // the partial-refund fix) a real hole in the split.\n //\n // Both tests are kept rather than replaced by the shared predicate: for a\n // NON-plugin artifact `isListingBrowsable` returns true whatever the\n // review status says, because pre-publication review is plugin-only. This\n // route has always been stricter than that, and a sale is the wrong place\n // to loosen a gate.\n //\n // No owner exemption, unlike the install routes: `canActAsPublisher`\n // below already refuses a publisher buying their own listing, so the\n // buyer here is never the owner.\n if (!isListingBrowsable(listing) || isPrivateListing(listing)) {\n return refuse(404, { error: 'Unknown listing' })\n }\n const priceUsd = Number(listing.priceUsd ?? 0)\n if (!(priceUsd > 0)) {\n return refuse(400, { error: 'Listing is free' })\n }\n // Publishing is org-owned (AGL-652): `profileId` IS the publisher org id.\n const sellerOrgId = String(listing.profileId ?? '')\n // ANY member of the publishing workspace, and any purchase FOR it\n // (AGL-3365). `canActAsPublisher` asks only for an owner or admin, so an\n // editor of the publisher — or an owner buying \"for\" the publisher's own\n // workspace through a role that can install — passed it, and a publisher\n // could pay itself: the stolen-card cash-out the payout exists to make\n // worth doing. A workspace one step removed is the sale screen's to flag.\n if (\n buyer.orgId === sellerOrgId ||\n (await canActAsPublisher(firestore, buyer.uid, sellerOrgId)) ||\n (await isOrgMember(firestore, sellerOrgId, buyer.uid))\n ) {\n return refuse(400, { error: 'Your organization published this listing' })\n }\n // You cannot buy the same component twice (AGL-1697).\n //\n // This route validated review status, self-purchase and the seller's\n // entitlements, and then never asked the one question the buyer cares\n // about: do they already own it. A marketplace listing is a buy-once good —\n // a second purchase buys nothing, since `requirePurchase` is satisfied by\n // the first — so a buyer who clicked Buy again from a stale tab, or came\n // back through a bookmarked checkout, was simply charged twice for it.\n //\n // Deliberately the SAME predicate the seven install routes gate on\n // (AGL-1546/1699) rather than a second query beside it: \"has this buyer\n // paid for this listing\" must have one answer, or the marketplace can take\n // money for something it will not install, or refuse to sell something it\n // will not install either. That predicate reads a fully REFUNDED purchase\n // as absent, which is exactly right here too — a refunded buyer must be\n // able to buy again, and a refunded row sitting beside a live one does not\n // veto it.\n //\n // 409 rather than 400: nothing about the request is malformed, the buyer\n // is just asking for a state that already holds.\n //\n // KEYED ON THE ORGANIZATION SINCE AGL-2331, and that is what turns this\n // guard from a blocked sale back into a real one. A licence belongs to a\n // workspace, so an agency developer who bought a component for one client\n // must be able to buy it again for the next — the person-keyed version of\n // this check answered `409 already_purchased` to exactly the second sale\n // we want. It still refuses the case it was written for: the same\n // workspace buying the same listing twice.\n //\n // A pre-AGL-2331 purchase still refuses, through the legacy grant inside\n // the shared predicate: that buyer can already install this listing\n // anywhere, so selling them a second copy would take money for nothing.\n if (\n await hasLivePurchase({\n firestore,\n buyerUid: buyer.uid,\n buyerOrgId: buyer.orgId,\n listingId,\n })\n ) {\n return refuse(409, {\n error:\n 'This organization already owns this listing — install it from ' +\n 'your library.',\n code: 'already_purchased',\n })\n }\n // The kill switch, on the SELL side too (AGL-2288).\n //\n // `revocations/{listingId}` with `versions: 'all'` is the listing-wide\n // kill: `install-plugin` 409s on it through this same predicate, and the\n // tenant runtime renders the disabled placeholder for anything already\n // pinned. Selling into that state takes money for an artifact the\n // platform has switched off.\n //\n // Only `'all'` is consulted, deliberately. A per-version revocation\n // (AGL-1085) stops one build while other approved versions stay\n // installable, and refusing the sale for it would turn a targeted control\n // into a de-listing. Whether the newest approved version happens to be\n // the revoked one is a question `install-plugin` answers with live data;\n // duplicating that resolution here would be a second copy of it.\n //\n // One extra read on a path that already makes several Firestore and\n // Stripe round trips, and it is the cheap half of the takedown check\n // above: a hand-written revocation doc — which the rules permit any staff\n // client to write — is caught here even when `hiddenAt` was never set.\n const revocation = (\n await firestore.collection('revocations').doc(listingId).get()\n ).data() as PluginRevocation | undefined\n if (isListingWideRevocation(revocation)) {\n return refuse(404, { error: 'Unknown listing' })\n }\n const publisher = await resolvePublisherProfile(firestore, sellerOrgId)\n const accountId = publisher?.stripeAccountId\n // The platform's cut comes from ENTITLEMENTS (AGL-1543), never the raw\n // `plan` field: `resolveMarketplaceFeePct` prices a dead subscription\n // as free-plan (30%) while the stale field still says pro, and honors\n // a negotiated per-org `marketplaceFeePct` override. Same read gates\n // SALE time on `marketplaceSelling` — publish already refuses without\n // it, and a publisher who downgrades must stop selling, not merely\n // stop publishing. Resolved per request; entitlements are never cached.\n const sellerOrgDoc = await firestore.collection('orgs').doc(sellerOrgId).get()\n const sellerOrg = (sellerOrgDoc.data() ?? {}) as any\n const feePercent = resolveMarketplaceFeePct(sellerOrg)\n // THE PLATFORM'S OWN ORG, AT THE STAFF WAIVER (AGL-4049). Nobody to pay, so\n // no account to be ready and no transfer to make. Asked here, ahead of the\n // payout gate, which is the only refusal it changes: a lock and the plan\n // below hold for it as for anyone.\n const platformSale = sellsAsPlatform({\n sellerOrgId,\n feePercent,\n platformOrgId: readPlatformOrgId(),\n })\n if (\n !platformSale &&\n !merchantAccountIsReady(\n {\n accountId,\n chargesEnabled: publisher?.stripeChargesEnabled,\n accountLivemode: publisher?.stripeAccountLivemode,\n },\n { subject: `marketplace publisher org ${sellerOrgId}` },\n )\n ) {\n return refuse(409, { error: 'The publisher has not enabled payouts yet' })\n }\n // A LOCKED publisher sells nothing (AGL-3365). Any lock, not only a\n // security one: a sale to a workspace staff have stopped is money moving\n // to it, and the lockdown's payout pause cannot reach a share that is\n // never charged. The same answer as a taken-down listing — a lock is not\n // a buyer's business.\n if (sellerOrg.suspendedAt != null) {\n return refuse(404, { error: 'Unknown listing' })\n }\n if (!checkEntitlement(sellerOrg, 'marketplaceSelling')) {\n return refuse(409, {\n error:\n \"The publisher's current plan does not include marketplace selling\",\n })\n }\n const amountCents = Math.round(priceUsd * 100)\n const feeCents = Math.round((amountCents * feePercent) / 100)\n return {\n sale: {\n listing,\n siteStarter,\n sellerOrgId,\n feePercent,\n amountCents,\n feeCents,\n transferCents: platformSale ? 0 : amountCents - feeCents,\n accountId: platformSale ? undefined : accountId,\n platformSale,\n },\n }\n}\n"],"names":["merchantAccountIsReady","checkEntitlement","isListingWideRevocation","resolveMarketplaceFeePct","permissionRefusalText","SITE_STARTER_ARTIFACT_TYPE","isListingBrowsable","isPrivateListing","listingArtifactType","sellsAsPlatform","readPlatformOrgId","canActAsPublisher","resolvePublisherProfile","hasLivePurchase","isOrgMember","MARKETPLACE_TAX_CODE","refuse","status","body","refusal","resolveSale","input","listing","sellerOrgDoc","firestore","listingId","buyer","listingSnapshot","collection","doc","get","data","deletedAt","error","siteStarter","permissions","createHosts","installPlugins","reviewStatus","priceUsd","Number","sellerOrgId","String","profileId","orgId","uid","buyerUid","buyerOrgId","code","revocation","publisher","accountId","stripeAccountId","sellerOrg","feePercent","platformSale","platformOrgId","chargesEnabled","stripeChargesEnabled","accountLivemode","stripeAccountLivemode","subject","suspendedAt","amountCents","Math","round","feeCents","sale","transferCents","undefined"],"mappings":"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"}
@@ -0,0 +1,36 @@
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
+ * WHAT "PUBLISH AS A STARTER" READS BEFORE THE CLICK, ASKED OF THE SERVER
20
+ * (AGL-4049).
21
+ *
22
+ * `GET /api/marketplace/starter-publish-facts?hostId=` answers
23
+ * `StarterPublishFactsAnswer`: what the publish would capture, the built-in
24
+ * theme the site wears as the publish body carries it, and each setup step
25
+ * still in the way.
26
+ *
27
+ * The console's form works these out in the browser; the native apps ask
28
+ * here, so what a site's built-in theme is stays one function in core
29
+ * (`siteStarterBuiltinThemeOf`) rather than a port on each platform. It reads
30
+ * for the same person the door would take: the workspace's "Publish to the
31
+ * marketplace" (`marketplace.publish`), and the release flag, which staff
32
+ * pass. A refusal is also how an app knows to offer nothing. The door still
33
+ * decides: these are what it would say, read early.
34
+ */
35
+ export declare const starterPublishFactsHandler: PluginApiHandler;
36
+ export default starterPublishFactsHandler;
@@ -0,0 +1,135 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { permissionRefusalText } from "@aglyn/aglyn/app-utils/org-permissions";
18
+ import { planLabelGrantingFeature } from "@aglyn/aglyn/app-utils/plan-entitlements";
19
+ import { SITE_STARTER_MAX_SCREENS } from "@aglyn/aglyn/app-utils/site-starter";
20
+ import { siteStarterBuiltinThemeOf } from "@aglyn/aglyn/app-utils/site-starter-theme";
21
+ import { checkEntitlement } from "@aglyn/aglyn/server";
22
+ import { firebaseAdmin, getOrgForHost, isServerReleaseFlagOnForOrg } from "@aglyn/tenant-data-admin";
23
+ import { isRefusedIdToken } from "@aglyn/tenant-data-admin/server/id-token-refusal";
24
+ import { resolveOrgPermissions } from "@aglyn/tenant-runtime/org-permissions";
25
+ import { starterPublishFactsAnswer, starterPublishPages } from "../model/starter-publish.js";
26
+ import { PUBLISHER_PROFILES, resolvePublisherProfile } from "./publisher-profile.js";
27
+ import { SITE_STARTERS_UNRELEASED } from "./starters.js";
28
+ /**
29
+ * WHAT "PUBLISH AS A STARTER" READS BEFORE THE CLICK, ASKED OF THE SERVER
30
+ * (AGL-4049).
31
+ *
32
+ * `GET /api/marketplace/starter-publish-facts?hostId=` answers
33
+ * `StarterPublishFactsAnswer`: what the publish would capture, the built-in
34
+ * theme the site wears as the publish body carries it, and each setup step
35
+ * still in the way.
36
+ *
37
+ * The console's form works these out in the browser; the native apps ask
38
+ * here, so what a site's built-in theme is stays one function in core
39
+ * (`siteStarterBuiltinThemeOf`) rather than a port on each platform. It reads
40
+ * for the same person the door would take: the workspace's "Publish to the
41
+ * marketplace" (`marketplace.publish`), and the release flag, which staff
42
+ * pass. A refusal is also how an app knows to offer nothing. The door still
43
+ * decides: these are what it would say, read early.
44
+ */ export const starterPublishFactsHandler = async (req, res)=>{
45
+ var _ref, _req_headers_authorization;
46
+ var _req_query;
47
+ if (req.method !== 'GET') {
48
+ return res.status(405).json({
49
+ error: 'Method not allowed'
50
+ });
51
+ }
52
+ const hostId = String((_ref = (_req_query = req.query) == null ? void 0 : _req_query['hostId']) != null ? _ref : '').trim();
53
+ if (!hostId) return res.status(400).json({
54
+ error: 'Missing hostId'
55
+ });
56
+ const authorization = String((_req_headers_authorization = req.headers.authorization) != null ? _req_headers_authorization : '');
57
+ const idToken = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : undefined;
58
+ if (!idToken) return res.status(401).json({
59
+ error: 'Unauthenticated'
60
+ });
61
+ let decoded;
62
+ try {
63
+ decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
64
+ } catch (error) {
65
+ // Only a refused credential is the caller's 401; failing to check it is
66
+ // ours and keeps the dispatcher's 5xx.
67
+ if (!isRefusedIdToken(error)) throw error;
68
+ return res.status(401).json({
69
+ error: 'Unauthenticated'
70
+ });
71
+ }
72
+ try {
73
+ var _ref1;
74
+ const membership = await resolveOrgPermissions(decoded.uid, {
75
+ hostId
76
+ });
77
+ if (!membership.orgId || !membership.permissions.publishToMarketplace) {
78
+ return res.status(403).json({
79
+ error: permissionRefusalText('marketplace.publish')
80
+ });
81
+ }
82
+ const orgId = membership.orgId;
83
+ if (decoded['staff'] !== true && !await isServerReleaseFlagOnForOrg('release_site_starters', orgId)) {
84
+ return res.status(403).json({
85
+ error: SITE_STARTERS_UNRELEASED
86
+ });
87
+ }
88
+ const firestore = firebaseAdmin.app().firestore();
89
+ const hostRef = firestore.collection('hosts').doc(hostId);
90
+ const hostSnapshot = await hostRef.get();
91
+ if (!hostSnapshot.exists) return res.status(404).json({
92
+ error: 'Unknown site'
93
+ });
94
+ const host = hostSnapshot.data();
95
+ // The routed pages, at most one past the most a starter may have: the
96
+ // window the door walks, enough to say a site is past the limit.
97
+ const routing = host['screens'];
98
+ const routedIds = routing && typeof routing === 'object' && !Array.isArray(routing) ? Object.keys(routing).slice(0, SITE_STARTER_MAX_SCREENS + 1) : [];
99
+ const screens = routedIds.length ? await firestore.getAll(...routedIds.map((id)=>hostRef.collection('screens').doc(id))) : [];
100
+ const pages = starterPublishPages(routing, screens.filter((screen)=>screen.exists).map((screen)=>_extends({}, screen.data(), {
101
+ $id: screen.id
102
+ })));
103
+ const [orgForHost, profile, publisher] = await Promise.all([
104
+ getOrgForHost(hostId),
105
+ firestore.collection(PUBLISHER_PROFILES).doc(orgId).get(),
106
+ resolvePublisherProfile(firestore, orgId)
107
+ ]);
108
+ const answer = starterPublishFactsAnswer({
109
+ facts: {
110
+ plan: _extends({
111
+ ready: true,
112
+ locked: !checkEntitlement((_ref1 = orgForHost == null ? void 0 : orgForHost.org) != null ? _ref1 : {}, 'marketplaceSelling')
113
+ }, planLabelGrantingFeature('marketplaceSelling') ? {
114
+ planLabel: planLabelGrantingFeature('marketplaceSelling')
115
+ } : {}),
116
+ profile: profile.exists ? profile.data() : null,
117
+ pages,
118
+ sellsAsPlatform: (publisher == null ? void 0 : publisher.sellsAsPlatform) === true,
119
+ // The apps fix each step on a screen of their own.
120
+ marketplacePath: null,
121
+ pagesPath: null
122
+ },
123
+ builtin: siteStarterBuiltinThemeOf(host)
124
+ });
125
+ return res.status(200).json(answer);
126
+ } catch (error) {
127
+ console.error(error);
128
+ return res.status(500).json({
129
+ error: 'Could not read this site'
130
+ });
131
+ }
132
+ };
133
+ export default starterPublishFactsHandler;
134
+
135
+ //# sourceMappingURL=starter-publish-facts.js.map