@aglyn/plugins-marketplace 1.0.0-beta.143

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +7 -0
  3. package/package.json +51 -0
  4. package/src/index.d.ts +19 -0
  5. package/src/index.js +20 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/components/artifact-update-dialog.component.d.ts +27 -0
  8. package/src/lib/components/artifact-update-dialog.component.js +244 -0
  9. package/src/lib/components/artifact-update-dialog.component.js.map +1 -0
  10. package/src/lib/components/host-plugins-card.component.d.ts +23 -0
  11. package/src/lib/components/host-plugins-card.component.js +566 -0
  12. package/src/lib/components/host-plugins-card.component.js.map +1 -0
  13. package/src/lib/components/listing-content.component.d.ts +57 -0
  14. package/src/lib/components/listing-content.component.js +1880 -0
  15. package/src/lib/components/listing-content.component.js.map +1 -0
  16. package/src/lib/components/listing-image.component.d.ts +46 -0
  17. package/src/lib/components/listing-image.component.js +38 -0
  18. package/src/lib/components/listing-image.component.js.map +1 -0
  19. package/src/lib/components/listing-reviews.component.d.ts +22 -0
  20. package/src/lib/components/listing-reviews.component.js +419 -0
  21. package/src/lib/components/listing-reviews.component.js.map +1 -0
  22. package/src/lib/components/marketplace-browse.component.d.ts +38 -0
  23. package/src/lib/components/marketplace-browse.component.js +802 -0
  24. package/src/lib/components/marketplace-browse.component.js.map +1 -0
  25. package/src/lib/components/plugin-site-set-panel.component.d.ts +24 -0
  26. package/src/lib/components/plugin-site-set-panel.component.js +137 -0
  27. package/src/lib/components/plugin-site-set-panel.component.js.map +1 -0
  28. package/src/lib/components/plugin-site-set.component.d.ts +43 -0
  29. package/src/lib/components/plugin-site-set.component.js +425 -0
  30. package/src/lib/components/plugin-site-set.component.js.map +1 -0
  31. package/src/lib/components/rating-input.component.d.ts +3 -0
  32. package/src/lib/components/rating-input.component.js +47 -0
  33. package/src/lib/components/rating-input.component.js.map +1 -0
  34. package/src/lib/components/report-target.component.d.ts +36 -0
  35. package/src/lib/components/report-target.component.js +181 -0
  36. package/src/lib/components/report-target.component.js.map +1 -0
  37. package/src/lib/components/uninstall-impact-dialog.component.d.ts +40 -0
  38. package/src/lib/components/uninstall-impact-dialog.component.js +244 -0
  39. package/src/lib/components/uninstall-impact-dialog.component.js.map +1 -0
  40. package/src/lib/constants/bundle-common.d.ts +8 -0
  41. package/src/lib/constants/bundle-common.js +9 -0
  42. package/src/lib/constants/bundle-common.js.map +1 -0
  43. package/src/lib/hooks/use-artifact-update.d.ts +41 -0
  44. package/src/lib/hooks/use-artifact-update.js +169 -0
  45. package/src/lib/hooks/use-artifact-update.js.map +1 -0
  46. package/src/lib/hooks/use-marketplace-actions.d.ts +37 -0
  47. package/src/lib/hooks/use-marketplace-actions.js +523 -0
  48. package/src/lib/hooks/use-marketplace-actions.js.map +1 -0
  49. package/src/lib/model/artifact-merge.d.ts +154 -0
  50. package/src/lib/model/artifact-merge.js +228 -0
  51. package/src/lib/model/artifact-merge.js.map +1 -0
  52. package/src/lib/model/index.d.ts +43 -0
  53. package/src/lib/model/index.js +41 -0
  54. package/src/lib/model/index.js.map +1 -0
  55. package/src/lib/model/marketplace-props.d.ts +57 -0
  56. package/src/lib/model/marketplace-props.js +441 -0
  57. package/src/lib/model/marketplace-props.js.map +1 -0
  58. package/src/lib/model/marketplace.d.ts +833 -0
  59. package/src/lib/model/marketplace.js +929 -0
  60. package/src/lib/model/marketplace.js.map +1 -0
  61. package/src/lib/model/rating-field.d.ts +24 -0
  62. package/src/lib/model/rating-field.js +33 -0
  63. package/src/lib/model/rating-field.js.map +1 -0
  64. package/src/lib/plugin.d.ts +33 -0
  65. package/src/lib/plugin.js +86 -0
  66. package/src/lib/plugin.js.map +1 -0
  67. package/src/lib/server/billing-webhook.d.ts +25 -0
  68. package/src/lib/server/billing-webhook.js +1062 -0
  69. package/src/lib/server/billing-webhook.js.map +1 -0
  70. package/src/lib/server/checkout.d.ts +76 -0
  71. package/src/lib/server/checkout.js +477 -0
  72. package/src/lib/server/checkout.js.map +1 -0
  73. package/src/lib/server/connect.d.ts +24 -0
  74. package/src/lib/server/connect.js +205 -0
  75. package/src/lib/server/connect.js.map +1 -0
  76. package/src/lib/server/install-dataset-schema.d.ts +32 -0
  77. package/src/lib/server/install-dataset-schema.js +255 -0
  78. package/src/lib/server/install-dataset-schema.js.map +1 -0
  79. package/src/lib/server/install-email-starter.d.ts +65 -0
  80. package/src/lib/server/install-email-starter.js +279 -0
  81. package/src/lib/server/install-email-starter.js.map +1 -0
  82. package/src/lib/server/install-email-template.d.ts +34 -0
  83. package/src/lib/server/install-email-template.js +252 -0
  84. package/src/lib/server/install-email-template.js.map +1 -0
  85. package/src/lib/server/install-layout.d.ts +29 -0
  86. package/src/lib/server/install-layout.js +323 -0
  87. package/src/lib/server/install-layout.js.map +1 -0
  88. package/src/lib/server/install-pin-counts.d.ts +173 -0
  89. package/src/lib/server/install-pin-counts.js +248 -0
  90. package/src/lib/server/install-pin-counts.js.map +1 -0
  91. package/src/lib/server/install-plugin.d.ts +18 -0
  92. package/src/lib/server/install-plugin.js +405 -0
  93. package/src/lib/server/install-plugin.js.map +1 -0
  94. package/src/lib/server/install-template.d.ts +37 -0
  95. package/src/lib/server/install-template.js +348 -0
  96. package/src/lib/server/install-template.js.map +1 -0
  97. package/src/lib/server/install-theme.d.ts +42 -0
  98. package/src/lib/server/install-theme.js +313 -0
  99. package/src/lib/server/install-theme.js.map +1 -0
  100. package/src/lib/server/install.d.ts +27 -0
  101. package/src/lib/server/install.js +276 -0
  102. package/src/lib/server/install.js.map +1 -0
  103. package/src/lib/server/listing-versions.d.ts +26 -0
  104. package/src/lib/server/listing-versions.js +378 -0
  105. package/src/lib/server/listing-versions.js.map +1 -0
  106. package/src/lib/server/preview-image.d.ts +37 -0
  107. package/src/lib/server/preview-image.js +216 -0
  108. package/src/lib/server/preview-image.js.map +1 -0
  109. package/src/lib/server/provenance.d.ts +100 -0
  110. package/src/lib/server/provenance.js +118 -0
  111. package/src/lib/server/provenance.js.map +1 -0
  112. package/src/lib/server/publish-dataset-schema.d.ts +31 -0
  113. package/src/lib/server/publish-dataset-schema.js +202 -0
  114. package/src/lib/server/publish-dataset-schema.js.map +1 -0
  115. package/src/lib/server/publish-email-starter.d.ts +44 -0
  116. package/src/lib/server/publish-email-starter.js +231 -0
  117. package/src/lib/server/publish-email-starter.js.map +1 -0
  118. package/src/lib/server/publish-email-template.d.ts +33 -0
  119. package/src/lib/server/publish-email-template.js +211 -0
  120. package/src/lib/server/publish-email-template.js.map +1 -0
  121. package/src/lib/server/publish-layout.d.ts +33 -0
  122. package/src/lib/server/publish-layout.js +243 -0
  123. package/src/lib/server/publish-layout.js.map +1 -0
  124. package/src/lib/server/publish-plugin.d.ts +18 -0
  125. package/src/lib/server/publish-plugin.js +485 -0
  126. package/src/lib/server/publish-plugin.js.map +1 -0
  127. package/src/lib/server/publish-preconditions.d.ts +86 -0
  128. package/src/lib/server/publish-preconditions.js +160 -0
  129. package/src/lib/server/publish-preconditions.js.map +1 -0
  130. package/src/lib/server/publish-template.d.ts +27 -0
  131. package/src/lib/server/publish-template.js +215 -0
  132. package/src/lib/server/publish-template.js.map +1 -0
  133. package/src/lib/server/publish-theme.d.ts +38 -0
  134. package/src/lib/server/publish-theme.js +186 -0
  135. package/src/lib/server/publish-theme.js.map +1 -0
  136. package/src/lib/server/publish.d.ts +26 -0
  137. package/src/lib/server/publish.js +204 -0
  138. package/src/lib/server/publish.js.map +1 -0
  139. package/src/lib/server/publisher-profile-save.d.ts +30 -0
  140. package/src/lib/server/publisher-profile-save.js +226 -0
  141. package/src/lib/server/publisher-profile-save.js.map +1 -0
  142. package/src/lib/server/publisher-profile.d.ts +86 -0
  143. package/src/lib/server/publisher-profile.js +116 -0
  144. package/src/lib/server/publisher-profile.js.map +1 -0
  145. package/src/lib/server/purchase-entitlement.d.ts +83 -0
  146. package/src/lib/server/purchase-entitlement.js +118 -0
  147. package/src/lib/server/purchase-entitlement.js.map +1 -0
  148. package/src/lib/server/report.d.ts +32 -0
  149. package/src/lib/server/report.js +101 -0
  150. package/src/lib/server/report.js.map +1 -0
  151. package/src/lib/server/reviews.d.ts +41 -0
  152. package/src/lib/server/reviews.js +240 -0
  153. package/src/lib/server/reviews.js.map +1 -0
  154. package/src/lib/server/update-artifact.d.ts +19 -0
  155. package/src/lib/server/update-artifact.js +569 -0
  156. package/src/lib/server/update-artifact.js.map +1 -0
  157. package/src/lib/server/verification-request.d.ts +31 -0
  158. package/src/lib/server/verification-request.js +173 -0
  159. package/src/lib/server/verification-request.js.map +1 -0
  160. package/src/lib/server/version-stats.d.ts +202 -0
  161. package/src/lib/server/version-stats.js +305 -0
  162. package/src/lib/server/version-stats.js.map +1 -0
  163. package/src/lib/server.d.ts +30 -0
  164. package/src/lib/server.js +95 -0
  165. package/src/lib/server.js.map +1 -0
@@ -0,0 +1,226 @@
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 { _ as _extends } from "@swc/helpers/_/_extends";
17
+ import { firebaseAdmin } from "@aglyn/tenant-data-admin";
18
+ import { PUBLISHER_AGREEMENT_BYTES, PUBLISHER_AGREEMENT_SHA256, PUBLISHER_AGREEMENT_UNAVAILABLE_NOTICE, PUBLISHER_AGREEMENT_VERSION, publisherAgreementIsPublished } from "@aglyn/aglyn/app-utils/publisher-agreement";
19
+ import { isValidPublisherHandle, validatePublisherProfileContent } from "../model/marketplace.js";
20
+ import { canActAsPublisher, claimPublisherHandle, PublisherHandleTakenError, PUBLISHER_PROFILES } from "./publisher-profile.js";
21
+ /**
22
+ * Record an org's acceptance of the marketplace publisher agreement
23
+ * (AGL-1077).
24
+ *
25
+ * Server-owned, like the handle and the payout keys: an acceptance a client
26
+ * could write is not evidence of anything. The rules block the field, and
27
+ * this is the only path that sets it.
28
+ *
29
+ * The submitted version must be the one currently in force. A client that
30
+ * echoes back some other string is refused rather than recorded — otherwise
31
+ * "accepted v1 forever" becomes a way to never see a changed agreement, and
32
+ * the whole point of versioning it is that a change re-asks.
33
+ *
34
+ * And the document must exist (AGL-1660). The console hides the control while
35
+ * it does not, but the console is UI: this route is the only writer, so this
36
+ * is the only place the refusal is a fact. Recording an acceptance of text we
37
+ * never served would put a signature in the record with nothing behind it,
38
+ * which is strictly worse than having no record — it looks like evidence.
39
+ */ const acceptPublisherAgreement = async (req, res)=>{
40
+ var _ref, _ref1, _req_headers_authorization;
41
+ var _req_body, _req_body1;
42
+ const orgId = String((_ref = (_req_body = req.body) == null ? void 0 : _req_body.orgId) != null ? _ref : '');
43
+ const version = String((_ref1 = (_req_body1 = req.body) == null ? void 0 : _req_body1.version) != null ? _ref1 : '');
44
+ if (!orgId) return res.status(400).json({
45
+ error: 'Missing orgId'
46
+ });
47
+ if (!publisherAgreementIsPublished()) {
48
+ // 409, the same conflict-with-current-state answer a version mismatch
49
+ // gets: the request is well-formed, the world is not ready for it.
50
+ return res.status(409).json({
51
+ error: PUBLISHER_AGREEMENT_UNAVAILABLE_NOTICE,
52
+ unavailable: true,
53
+ currentVersion: PUBLISHER_AGREEMENT_VERSION
54
+ });
55
+ }
56
+ if (version !== PUBLISHER_AGREEMENT_VERSION) {
57
+ return res.status(409).json({
58
+ error: 'The agreement changed while you were reading it. Reload and read ' + 'the current version before accepting.',
59
+ currentVersion: PUBLISHER_AGREEMENT_VERSION
60
+ });
61
+ }
62
+ const authorization = String((_req_headers_authorization = req.headers.authorization) != null ? _req_headers_authorization : '');
63
+ const idToken = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : undefined;
64
+ if (!idToken) return res.status(401).json({
65
+ error: 'Unauthenticated'
66
+ });
67
+ try {
68
+ const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
69
+ const firestore = firebaseAdmin.app().firestore();
70
+ // The ORG is the party (AGL-652), and only someone who can bind it may
71
+ // say so — the same gate that owns the profile's public identity.
72
+ if (!await canActAsPublisher(firestore, decoded.uid, orgId)) {
73
+ return res.status(403).json({
74
+ error: 'Only an organization owner or admin can accept the publisher ' + 'agreement on its behalf'
75
+ });
76
+ }
77
+ await firestore.collection(PUBLISHER_PROFILES).doc(orgId).set({
78
+ publisherAgreement: {
79
+ version: PUBLISHER_AGREEMENT_VERSION,
80
+ // The snapshot pin (AGL-1678): the version is a label we control
81
+ // and can reuse, so the record also carries the sha256 and byte
82
+ // length of the archived snapshot of the text the publisher was
83
+ // shown — self-contained evidence of content, same machinery as
84
+ // the signup clickwrap (AGL-1497).
85
+ sha256: PUBLISHER_AGREEMENT_SHA256,
86
+ bytes: PUBLISHER_AGREEMENT_BYTES,
87
+ // A uid, not the org: "this organization agreed" is not a fact
88
+ // anybody can be held to. A named person on a date is.
89
+ acceptedBy: decoded.uid,
90
+ acceptedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp()
91
+ }
92
+ }, {
93
+ merge: true
94
+ });
95
+ return res.status(200).json({
96
+ ok: true,
97
+ version: PUBLISHER_AGREEMENT_VERSION
98
+ });
99
+ } catch (error) {
100
+ console.error(error);
101
+ return res.status(500).json({
102
+ error: 'Could not record the acceptance'
103
+ });
104
+ }
105
+ };
106
+ /**
107
+ * Save an org's publisher profile (AGL-652).
108
+ *
109
+ * The handle has to be claimed transactionally, and a client can't do that —
110
+ * a read-then-write uniqueness check lets two orgs racing for the same handle
111
+ * both succeed, and the loser silently loses its marketplace URL. So the
112
+ * handle is server-owned here, exactly like the host subdomain (AGL-642), and
113
+ * the rules freeze it against client writes.
114
+ *
115
+ * Manager-only: the profile is the org's public identity and its payout
116
+ * anchor, so it follows the same gate as the profile rules.
117
+ */ export const publisherProfileSaveHandler = async (req, res)=>{
118
+ var _ref, _ref1, _ref2, _req_headers_authorization;
119
+ var _req_body, _req_body1, _req_body2, _req_body3;
120
+ if (req.method !== 'POST') {
121
+ return res.status(405).json({
122
+ error: 'Method not allowed'
123
+ });
124
+ }
125
+ // Accepting the agreement (AGL-1077) is not a profile edit — it writes a
126
+ // field the profile save must never touch, so it takes its own branch
127
+ // rather than riding along with a name change.
128
+ if (((_req_body = req.body) == null ? void 0 : _req_body.action) === 'accept-agreement') {
129
+ return acceptPublisherAgreement(req, res);
130
+ }
131
+ const orgId = String((_ref = (_req_body1 = req.body) == null ? void 0 : _req_body1.orgId) != null ? _ref : '');
132
+ const handle = String((_ref1 = (_req_body2 = req.body) == null ? void 0 : _req_body2.handle) != null ? _ref1 : '').trim().toLowerCase();
133
+ const displayName = String((_ref2 = (_req_body3 = req.body) == null ? void 0 : _req_body3.displayName) != null ? _ref2 : '').trim().slice(0, 80);
134
+ if (!orgId) return res.status(400).json({
135
+ error: 'Missing orgId'
136
+ });
137
+ if (!displayName) {
138
+ return res.status(400).json({
139
+ error: 'A display name is required'
140
+ });
141
+ }
142
+ if (!isValidPublisherHandle(handle)) {
143
+ return res.status(400).json({
144
+ error: 'Handles are 3–30 characters: lowercase letters, numbers and ' + 'hyphens, starting and ending alphanumeric.'
145
+ });
146
+ }
147
+ const authorization = String((_req_headers_authorization = req.headers.authorization) != null ? _req_headers_authorization : '');
148
+ const idToken = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : undefined;
149
+ if (!idToken) return res.status(401).json({
150
+ error: 'Unauthenticated'
151
+ });
152
+ try {
153
+ var _ref3, _req_body4, _extras_content;
154
+ var _req_body5, _req_body6;
155
+ const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
156
+ const firestore = firebaseAdmin.app().firestore();
157
+ if (!await canActAsPublisher(firestore, decoded.uid, orgId)) {
158
+ return res.status(403).json({
159
+ error: 'Only an organization owner or admin can edit the publisher profile'
160
+ });
161
+ }
162
+ const ref = firestore.collection(PUBLISHER_PROFILES).doc(orgId);
163
+ const previousHandle = (await ref.get()).get('handle');
164
+ // Claim first: if the handle is taken there must be no profile write, or
165
+ // the org would show a handle it doesn't actually hold.
166
+ try {
167
+ await claimPublisherHandle(firestore, orgId, handle, previousHandle);
168
+ } catch (error) {
169
+ if (error instanceof PublisherHandleTakenError) {
170
+ return res.status(409).json({
171
+ error: 'That handle is already taken'
172
+ });
173
+ }
174
+ throw error;
175
+ }
176
+ // PRESENT-AND-EMPTY CLEARS; ABSENT LEAVES ALONE.
177
+ //
178
+ // The write below is a merge, and `bio` was spread in only when truthy, so
179
+ // an empty one was a no-op and the stored bio survived every attempt to
180
+ // remove it. The publisher could rewrite it but never take it down — while
181
+ // every URL field in this same write has cleared on empty since AGL-1009.
182
+ //
183
+ // Presence is what separates "clear this" from "this request is not about
184
+ // the bio": `req.body.bio` absent must not delete a bio the caller never
185
+ // mentioned, which a bare `?? ''` would do to any client that omits it.
186
+ const bioProvided = ((_req_body5 = req.body) == null ? void 0 : _req_body5.bio) !== undefined;
187
+ const bio = String((_ref3 = (_req_body6 = req.body) == null ? void 0 : _req_body6.bio) != null ? _ref3 : '').trim().slice(0, 500);
188
+ // Logo, support contact and social links (AGL-1009): validated here on
189
+ // the save route — the same server-owned discipline as the handle — and
190
+ // through `validatePublisherProfileContent`, which shares the listing
191
+ // validator's https-only rule rather than growing a second URL parser.
192
+ const extras = validatePublisherProfileContent((_req_body4 = req.body) != null ? _req_body4 : {});
193
+ if (!extras.ok) {
194
+ return res.status(400).json({
195
+ error: extras.error
196
+ });
197
+ }
198
+ const extraWrites = {};
199
+ for (const [key, value] of Object.entries((_extras_content = extras.content) != null ? _extras_content : {})){
200
+ // An explicit empty string clears the stored field — otherwise a
201
+ // publisher could add a link but never take one down.
202
+ extraWrites[key] = value === '' ? firebaseAdmin.firestore.FieldValue.delete() : value;
203
+ }
204
+ await ref.set(_extends({
205
+ handle,
206
+ displayName
207
+ }, bioProvided ? {
208
+ bio: bio ? bio : firebaseAdmin.firestore.FieldValue.delete()
209
+ } : {}, extraWrites, {
210
+ updatedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp()
211
+ }), {
212
+ merge: true
213
+ });
214
+ return res.status(200).json({
215
+ ok: true,
216
+ handle
217
+ });
218
+ } catch (error) {
219
+ console.error(error);
220
+ return res.status(500).json({
221
+ error: 'Could not save the profile'
222
+ });
223
+ }
224
+ };
225
+
226
+ //# sourceMappingURL=publisher-profile-save.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/publisher-profile-save.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 { type PluginApiHandler } from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport {\n PUBLISHER_AGREEMENT_BYTES,\n PUBLISHER_AGREEMENT_SHA256,\n PUBLISHER_AGREEMENT_UNAVAILABLE_NOTICE,\n PUBLISHER_AGREEMENT_VERSION,\n publisherAgreementIsPublished,\n} from '@aglyn/aglyn/app-utils/publisher-agreement'\nimport {\n isValidPublisherHandle,\n validatePublisherProfileContent,\n} from '../model/marketplace'\nimport {\n canActAsPublisher,\n claimPublisherHandle,\n PublisherHandleTakenError,\n PUBLISHER_PROFILES,\n} from './publisher-profile'\n\n/**\n * Record an org's acceptance of the marketplace publisher agreement\n * (AGL-1077).\n *\n * Server-owned, like the handle and the payout keys: an acceptance a client\n * could write is not evidence of anything. The rules block the field, and\n * this is the only path that sets it.\n *\n * The submitted version must be the one currently in force. A client that\n * echoes back some other string is refused rather than recorded — otherwise\n * \"accepted v1 forever\" becomes a way to never see a changed agreement, and\n * the whole point of versioning it is that a change re-asks.\n *\n * And the document must exist (AGL-1660). The console hides the control while\n * it does not, but the console is UI: this route is the only writer, so this\n * is the only place the refusal is a fact. Recording an acceptance of text we\n * never served would put a signature in the record with nothing behind it,\n * which is strictly worse than having no record — it looks like evidence.\n */\nconst acceptPublisherAgreement: PluginApiHandler = async (req, res) => {\n const orgId = String(req.body?.orgId ?? '')\n const version = String(req.body?.version ?? '')\n if (!orgId) return res.status(400).json({ error: 'Missing orgId' })\n if (!publisherAgreementIsPublished()) {\n // 409, the same conflict-with-current-state answer a version mismatch\n // gets: the request is well-formed, the world is not ready for it.\n return res.status(409).json({\n error: PUBLISHER_AGREEMENT_UNAVAILABLE_NOTICE,\n unavailable: true,\n currentVersion: PUBLISHER_AGREEMENT_VERSION,\n })\n }\n if (version !== PUBLISHER_AGREEMENT_VERSION) {\n return res.status(409).json({\n error:\n 'The agreement changed while you were reading it. Reload and read ' +\n 'the current version before accepting.',\n currentVersion: PUBLISHER_AGREEMENT_VERSION,\n })\n }\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n // The ORG is the party (AGL-652), and only someone who can bind it may\n // say so — the same gate that owns the profile's public identity.\n if (!(await canActAsPublisher(firestore, decoded.uid, orgId))) {\n return res.status(403).json({\n error:\n 'Only an organization owner or admin can accept the publisher ' +\n 'agreement on its behalf',\n })\n }\n await firestore\n .collection(PUBLISHER_PROFILES)\n .doc(orgId)\n .set(\n {\n publisherAgreement: {\n version: PUBLISHER_AGREEMENT_VERSION,\n // The snapshot pin (AGL-1678): the version is a label we control\n // and can reuse, so the record also carries the sha256 and byte\n // length of the archived snapshot of the text the publisher was\n // shown — self-contained evidence of content, same machinery as\n // the signup clickwrap (AGL-1497).\n sha256: PUBLISHER_AGREEMENT_SHA256,\n bytes: PUBLISHER_AGREEMENT_BYTES,\n // A uid, not the org: \"this organization agreed\" is not a fact\n // anybody can be held to. A named person on a date is.\n acceptedBy: decoded.uid,\n acceptedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n },\n },\n { merge: true },\n )\n return res\n .status(200)\n .json({ ok: true, version: PUBLISHER_AGREEMENT_VERSION })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Could not record the acceptance' })\n }\n}\n\n/**\n * Save an org's publisher profile (AGL-652).\n *\n * The handle has to be claimed transactionally, and a client can't do that —\n * a read-then-write uniqueness check lets two orgs racing for the same handle\n * both succeed, and the loser silently loses its marketplace URL. So the\n * handle is server-owned here, exactly like the host subdomain (AGL-642), and\n * the rules freeze it against client writes.\n *\n * Manager-only: the profile is the org's public identity and its payout\n * anchor, so it follows the same gate as the profile rules.\n */\nexport const publisherProfileSaveHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n // Accepting the agreement (AGL-1077) is not a profile edit — it writes a\n // field the profile save must never touch, so it takes its own branch\n // rather than riding along with a name change.\n if (req.body?.action === 'accept-agreement') {\n return acceptPublisherAgreement(req, res)\n }\n const orgId = String(req.body?.orgId ?? '')\n const handle = String(req.body?.handle ?? '').trim().toLowerCase()\n const displayName = String(req.body?.displayName ?? '').trim().slice(0, 80)\n if (!orgId) return res.status(400).json({ error: 'Missing orgId' })\n if (!displayName) {\n return res.status(400).json({ error: 'A display name is required' })\n }\n if (!isValidPublisherHandle(handle)) {\n return res.status(400).json({\n error:\n 'Handles are 3–30 characters: lowercase letters, numbers and ' +\n 'hyphens, starting and ending alphanumeric.',\n })\n }\n\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n if (!(await canActAsPublisher(firestore, decoded.uid, orgId))) {\n return res.status(403).json({\n error: 'Only an organization owner or admin can edit the publisher profile',\n })\n }\n\n const ref = firestore.collection(PUBLISHER_PROFILES).doc(orgId)\n const previousHandle = (await ref.get()).get('handle') as string | undefined\n\n // Claim first: if the handle is taken there must be no profile write, or\n // the org would show a handle it doesn't actually hold.\n try {\n await claimPublisherHandle(firestore, orgId, handle, previousHandle)\n } catch (error) {\n if (error instanceof PublisherHandleTakenError) {\n return res.status(409).json({ error: 'That handle is already taken' })\n }\n throw error\n }\n\n // PRESENT-AND-EMPTY CLEARS; ABSENT LEAVES ALONE.\n //\n // The write below is a merge, and `bio` was spread in only when truthy, so\n // an empty one was a no-op and the stored bio survived every attempt to\n // remove it. The publisher could rewrite it but never take it down — while\n // every URL field in this same write has cleared on empty since AGL-1009.\n //\n // Presence is what separates \"clear this\" from \"this request is not about\n // the bio\": `req.body.bio` absent must not delete a bio the caller never\n // mentioned, which a bare `?? ''` would do to any client that omits it.\n const bioProvided = req.body?.bio !== undefined\n const bio = String(req.body?.bio ?? '').trim().slice(0, 500)\n // Logo, support contact and social links (AGL-1009): validated here on\n // the save route — the same server-owned discipline as the handle — and\n // through `validatePublisherProfileContent`, which shares the listing\n // validator's https-only rule rather than growing a second URL parser.\n const extras = validatePublisherProfileContent(req.body ?? {})\n if (!extras.ok) {\n return res.status(400).json({ error: extras.error })\n }\n const extraWrites: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(extras.content ?? {})) {\n // An explicit empty string clears the stored field — otherwise a\n // publisher could add a link but never take one down.\n extraWrites[key] =\n value === '' ? firebaseAdmin.firestore.FieldValue.delete() : value\n }\n await ref.set(\n {\n handle,\n displayName,\n ...(bioProvided\n ? {\n bio: bio\n ? bio\n : firebaseAdmin.firestore.FieldValue.delete(),\n }\n : {}),\n ...extraWrites,\n updatedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n return res.status(200).json({ ok: true, handle })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Could not save the profile' })\n }\n}\n"],"names":["firebaseAdmin","PUBLISHER_AGREEMENT_BYTES","PUBLISHER_AGREEMENT_SHA256","PUBLISHER_AGREEMENT_UNAVAILABLE_NOTICE","PUBLISHER_AGREEMENT_VERSION","publisherAgreementIsPublished","isValidPublisherHandle","validatePublisherProfileContent","canActAsPublisher","claimPublisherHandle","PublisherHandleTakenError","PUBLISHER_PROFILES","acceptPublisherAgreement","req","res","orgId","String","body","version","status","json","error","unavailable","currentVersion","authorization","headers","idToken","startsWith","slice","length","undefined","decoded","app","auth","verifyIdToken","firestore","uid","collection","doc","set","publisherAgreement","sha256","bytes","acceptedBy","acceptedAt","FieldValue","serverTimestamp","merge","ok","console","publisherProfileSaveHandler","method","action","handle","trim","toLowerCase","displayName","extras","ref","previousHandle","get","bioProvided","bio","extraWrites","key","value","Object","entries","content","delete","updatedAt"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC;AAGD,SAASA,aAAa,QAAQ,2BAA0B;AACxD,SACEC,yBAAyB,EACzBC,0BAA0B,EAC1BC,sCAAsC,EACtCC,2BAA2B,EAC3BC,6BAA6B,QACxB,6CAA4C;AACnD,SACEC,sBAAsB,EACtBC,+BAA+B,QAC1B,0BAAsB;AAC7B,SACEC,iBAAiB,EACjBC,oBAAoB,EACpBC,yBAAyB,EACzBC,kBAAkB,QACb,yBAAqB;AAE5B;;;;;;;;;;;;;;;;;;CAkBC,GACD,MAAMC,2BAA6C,OAAOC,KAAKC;qBAqBhCD;QApBRA,WACEA;IADvB,MAAME,QAAQC,gBAAOH,YAAAA,IAAII,IAAI,qBAARJ,UAAUE,KAAK,mBAAI;IACxC,MAAMG,UAAUF,iBAAOH,aAAAA,IAAII,IAAI,qBAARJ,WAAUK,OAAO,oBAAI;IAC5C,IAAI,CAACH,OAAO,OAAOD,IAAIK,MAAM,CAAC,KAAKC,IAAI,CAAC;QAAEC,OAAO;IAAgB;IACjE,IAAI,CAAChB,iCAAiC;QACpC,sEAAsE;QACtE,mEAAmE;QACnE,OAAOS,IAAIK,MAAM,CAAC,KAAKC,IAAI,CAAC;YAC1BC,OAAOlB;YACPmB,aAAa;YACbC,gBAAgBnB;QAClB;IACF;IACA,IAAIc,YAAYd,6BAA6B;QAC3C,OAAOU,IAAIK,MAAM,CAAC,KAAKC,IAAI,CAAC;YAC1BC,OACE,sEACA;YACFE,gBAAgBnB;QAClB;IACF;IACA,MAAMoB,gBAAgBR,QAAOH,6BAAAA,IAAIY,OAAO,CAACD,aAAa,YAAzBX,6BAA6B;IAC1D,MAAMa,UAAUF,cAAcG,UAAU,CAAC,aACrCH,cAAcI,KAAK,CAAC,UAAUC,MAAM,IACpCC;IACJ,IAAI,CAACJ,SAAS,OAAOZ,IAAIK,MAAM,CAAC,KAAKC,IAAI,CAAC;QAAEC,OAAO;IAAkB;IACrE,IAAI;QACF,MAAMU,UAAU,MAAM/B,cAAcgC,GAAG,GAAGC,IAAI,GAAGC,aAAa,CAACR;QAC/D,MAAMS,YAAYnC,cAAcgC,GAAG,GAAGG,SAAS;QAC/C,uEAAuE;QACvE,kEAAkE;QAClE,IAAI,CAAE,MAAM3B,kBAAkB2B,WAAWJ,QAAQK,GAAG,EAAErB,QAAS;YAC7D,OAAOD,IAAIK,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAC1BC,OACE,kEACA;YACJ;QACF;QACA,MAAMc,UACHE,UAAU,CAAC1B,oBACX2B,GAAG,CAACvB,OACJwB,GAAG,CACF;YACEC,oBAAoB;gBAClBtB,SAASd;gBACT,iEAAiE;gBACjE,gEAAgE;gBAChE,gEAAgE;gBAChE,gEAAgE;gBAChE,mCAAmC;gBACnCqC,QAAQvC;gBACRwC,OAAOzC;gBACP,+DAA+D;gBAC/D,uDAAuD;gBACvD0C,YAAYZ,QAAQK,GAAG;gBACvBQ,YAAY5C,cAAcmC,SAAS,CAACU,UAAU,CAACC,eAAe;YAChE;QACF,GACA;YAAEC,OAAO;QAAK;QAElB,OAAOjC,IACJK,MAAM,CAAC,KACPC,IAAI,CAAC;YAAE4B,IAAI;YAAM9B,SAASd;QAA4B;IAC3D,EAAE,OAAOiB,OAAO;QACd4B,QAAQ5B,KAAK,CAACA;QACd,OAAOP,IAAIK,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAkC;IACzE;AACF;AAEA;;;;;;;;;;;CAWC,GACD,OAAO,MAAM6B,8BAAgD,OAAOrC,KAAKC;4BAyB1CD;QAlBzBA,WAGiBA,YACCA,YACKA;IAX3B,IAAIA,IAAIsC,MAAM,KAAK,QAAQ;QACzB,OAAOrC,IAAIK,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAqB;IAC5D;IACA,yEAAyE;IACzE,sEAAsE;IACtE,+CAA+C;IAC/C,IAAIR,EAAAA,YAAAA,IAAII,IAAI,qBAARJ,UAAUuC,MAAM,MAAK,oBAAoB;QAC3C,OAAOxC,yBAAyBC,KAAKC;IACvC;IACA,MAAMC,QAAQC,gBAAOH,aAAAA,IAAII,IAAI,qBAARJ,WAAUE,KAAK,mBAAI;IACxC,MAAMsC,SAASrC,iBAAOH,aAAAA,IAAII,IAAI,qBAARJ,WAAUwC,MAAM,oBAAI,IAAIC,IAAI,GAAGC,WAAW;IAChE,MAAMC,cAAcxC,iBAAOH,aAAAA,IAAII,IAAI,qBAARJ,WAAU2C,WAAW,oBAAI,IAAIF,IAAI,GAAG1B,KAAK,CAAC,GAAG;IACxE,IAAI,CAACb,OAAO,OAAOD,IAAIK,MAAM,CAAC,KAAKC,IAAI,CAAC;QAAEC,OAAO;IAAgB;IACjE,IAAI,CAACmC,aAAa;QAChB,OAAO1C,IAAIK,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAA6B;IACpE;IACA,IAAI,CAACf,uBAAuB+C,SAAS;QACnC,OAAOvC,IAAIK,MAAM,CAAC,KAAKC,IAAI,CAAC;YAC1BC,OACE,iEACA;QACJ;IACF;IAEA,MAAMG,gBAAgBR,QAAOH,6BAAAA,IAAIY,OAAO,CAACD,aAAa,YAAzBX,6BAA6B;IAC1D,MAAMa,UAAUF,cAAcG,UAAU,CAAC,aACrCH,cAAcI,KAAK,CAAC,UAAUC,MAAM,IACpCC;IACJ,IAAI,CAACJ,SAAS,OAAOZ,IAAIK,MAAM,CAAC,KAAKC,IAAI,CAAC;QAAEC,OAAO;IAAkB;IAErE,IAAI;mBAuC6CR,YAKL4C;YAXtB5C,YACDA;QAjCnB,MAAMkB,UAAU,MAAM/B,cAAcgC,GAAG,GAAGC,IAAI,GAAGC,aAAa,CAACR;QAC/D,MAAMS,YAAYnC,cAAcgC,GAAG,GAAGG,SAAS;QAC/C,IAAI,CAAE,MAAM3B,kBAAkB2B,WAAWJ,QAAQK,GAAG,EAAErB,QAAS;YAC7D,OAAOD,IAAIK,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAC1BC,OAAO;YACT;QACF;QAEA,MAAMqC,MAAMvB,UAAUE,UAAU,CAAC1B,oBAAoB2B,GAAG,CAACvB;QACzD,MAAM4C,iBAAiB,AAAC,CAAA,MAAMD,IAAIE,GAAG,EAAC,EAAGA,GAAG,CAAC;QAE7C,yEAAyE;QACzE,wDAAwD;QACxD,IAAI;YACF,MAAMnD,qBAAqB0B,WAAWpB,OAAOsC,QAAQM;QACvD,EAAE,OAAOtC,OAAO;YACd,IAAIA,iBAAiBX,2BAA2B;gBAC9C,OAAOI,IAAIK,MAAM,CAAC,KAAKC,IAAI,CAAC;oBAAEC,OAAO;gBAA+B;YACtE;YACA,MAAMA;QACR;QAEA,iDAAiD;QACjD,EAAE;QACF,2EAA2E;QAC3E,wEAAwE;QACxE,2EAA2E;QAC3E,0EAA0E;QAC1E,EAAE;QACF,0EAA0E;QAC1E,yEAAyE;QACzE,wEAAwE;QACxE,MAAMwC,cAAchD,EAAAA,aAAAA,IAAII,IAAI,qBAARJ,WAAUiD,GAAG,MAAKhC;QACtC,MAAMgC,MAAM9C,iBAAOH,aAAAA,IAAII,IAAI,qBAARJ,WAAUiD,GAAG,oBAAI,IAAIR,IAAI,GAAG1B,KAAK,CAAC,GAAG;QACxD,uEAAuE;QACvE,wEAAwE;QACxE,sEAAsE;QACtE,uEAAuE;QACvE,MAAM6B,SAASlD,iCAAgCM,aAAAA,IAAII,IAAI,YAARJ,aAAY,CAAC;QAC5D,IAAI,CAAC4C,OAAOT,EAAE,EAAE;YACd,OAAOlC,IAAIK,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAOoC,OAAOpC,KAAK;YAAC;QACpD;QACA,MAAM0C,cAAuC,CAAC;QAC9C,KAAK,MAAM,CAACC,KAAKC,MAAM,IAAIC,OAAOC,OAAO,EAACV,kBAAAA,OAAOW,OAAO,YAAdX,kBAAkB,CAAC,GAAI;YAC/D,iEAAiE;YACjE,sDAAsD;YACtDM,WAAW,CAACC,IAAI,GACdC,UAAU,KAAKjE,cAAcmC,SAAS,CAACU,UAAU,CAACwB,MAAM,KAAKJ;QACjE;QACA,MAAMP,IAAInB,GAAG,CACX;YACEc;YACAG;WACIK,cACA;YACEC,KAAKA,MACDA,MACA9D,cAAcmC,SAAS,CAACU,UAAU,CAACwB,MAAM;QAC/C,IACA,CAAC,GACFN;YACHO,WAAWtE,cAAcmC,SAAS,CAACU,UAAU,CAACC,eAAe;YAE/D;YAAEC,OAAO;QAAK;QAEhB,OAAOjC,IAAIK,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAE4B,IAAI;YAAMK;QAAO;IACjD,EAAE,OAAOhC,OAAO;QACd4B,QAAQ5B,KAAK,CAACA;QACd,OAAOP,IAAIK,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAA6B;IACpE;AACF,EAAC"}
@@ -0,0 +1,86 @@
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 PublisherAgreementAcceptance } from '@aglyn/aglyn/app-utils/publisher-agreement';
18
+ /** `publisherProfiles/{orgId}` — the org's marketplace identity (AGL-652). */
19
+ export declare const PUBLISHER_PROFILES = "publisherProfiles";
20
+ /** `publisherHandles/{handle}` — uniqueness reservations. */
21
+ export declare const PUBLISHER_HANDLES = "publisherHandles";
22
+ export interface ResolvedPublisher {
23
+ orgId: string;
24
+ handle: string;
25
+ displayName?: string;
26
+ stripeAccountId?: string;
27
+ stripeChargesEnabled?: boolean;
28
+ /**
29
+ * Which Stripe world the payout account belongs to (AGL-2471).
30
+ *
31
+ * PROJECTED, and that is the load-bearing part. The sale gate in
32
+ * `checkout.ts` reads this shape, not the raw document — an API projection
33
+ * that drops a field its predicate needs turns the predicate into a
34
+ * constant, and here the constant would be "refuse every marketplace sale".
35
+ * Left three-valued deliberately: `undefined` means never established and
36
+ * must stay distinguishable from `false`.
37
+ */
38
+ stripeAccountLivemode?: boolean;
39
+ /**
40
+ * Which marketplace publisher agreement this ORG has accepted (AGL-1077).
41
+ * Absent on every profile created before the agreement existed, which is
42
+ * why publishing refuses rather than assuming consent.
43
+ */
44
+ agreement?: PublisherAgreementAcceptance;
45
+ }
46
+ /**
47
+ * Load the publishing org's marketplace profile (AGL-652).
48
+ *
49
+ * Publishing is org-only: the publisher is the ORG, not the person who
50
+ * happened to click publish. Every publish route resolves through here so
51
+ * they can't drift on which identity a listing is attributed to.
52
+ *
53
+ * Returns `null` when the org has no profile yet — callers turn that into a
54
+ * 412 telling the user to set one up, matching the previous behaviour when a
55
+ * personal profile was missing.
56
+ */
57
+ export declare function resolvePublisherProfile(firestore: FirebaseFirestore.Firestore, orgId: string): Promise<ResolvedPublisher | null>;
58
+ /**
59
+ * Whether a user may act for a publishing org (AGL-652).
60
+ *
61
+ * Ownership checks used to read `listing.profileId === decoded.uid`. With
62
+ * org-owned listings that comparison can NEVER be true — a uid is not an org
63
+ * id — so leaving them would lock every publisher out of their own listing
64
+ * and make every paid listing chargeable to its own publisher. Managers
65
+ * (owner/admin) act for the org, matching the gate the profile rules apply.
66
+ */
67
+ export declare function canActAsPublisher(firestore: FirebaseFirestore.Firestore, uid: string, publisherOrgId: string | undefined | null): Promise<boolean>;
68
+ export declare class PublisherHandleTakenError extends Error {
69
+ readonly handle: string;
70
+ constructor(handle: string);
71
+ }
72
+ /**
73
+ * Claim a marketplace handle for an org, transactionally (AGL-652).
74
+ *
75
+ * Mirrors the `orgSlugs` reservation: the read and the write share one
76
+ * transaction, so two orgs racing for the same handle can't both win — which
77
+ * a read-then-write check permits, and which the marketplace URL space cannot
78
+ * represent. A handle the org already holds is a no-op, so this is safe to
79
+ * call on every profile save.
80
+ *
81
+ * A renamed handle leaves a `movedTo` tombstone behind so old marketplace
82
+ * links can still resolve, and re-claiming a tombstone you own is allowed.
83
+ *
84
+ * @throws PublisherHandleTakenError when another org holds it.
85
+ */
86
+ export declare function claimPublisherHandle(firestore: FirebaseFirestore.Firestore, orgId: string, handle: string, previousHandle?: string): Promise<void>;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { firebaseAdmin } from "@aglyn/tenant-data-admin";
17
+ import { isValidPublisherHandle } from "../model/marketplace.js";
18
+ /** `publisherProfiles/{orgId}` — the org's marketplace identity (AGL-652). */ export const PUBLISHER_PROFILES = 'publisherProfiles';
19
+ /** `publisherHandles/{handle}` — uniqueness reservations. */ export const PUBLISHER_HANDLES = 'publisherHandles';
20
+ /**
21
+ * Load the publishing org's marketplace profile (AGL-652).
22
+ *
23
+ * Publishing is org-only: the publisher is the ORG, not the person who
24
+ * happened to click publish. Every publish route resolves through here so
25
+ * they can't drift on which identity a listing is attributed to.
26
+ *
27
+ * Returns `null` when the org has no profile yet — callers turn that into a
28
+ * 412 telling the user to set one up, matching the previous behaviour when a
29
+ * personal profile was missing.
30
+ */ export async function resolvePublisherProfile(firestore, orgId) {
31
+ const snapshot = await firestore.collection(PUBLISHER_PROFILES).doc(orgId).get();
32
+ const handle = snapshot.get('handle');
33
+ if (!snapshot.exists || !handle) return null;
34
+ return {
35
+ orgId,
36
+ handle,
37
+ displayName: snapshot.get('displayName'),
38
+ stripeAccountId: snapshot.get('stripeAccountId'),
39
+ stripeChargesEnabled: snapshot.get('stripeChargesEnabled') === true,
40
+ // NOT coerced with `=== true` like the flag above: `false` (a test-mode
41
+ // account) and absent (never checked) are different answers, and
42
+ // flattening them would hide which of the two a publisher is in.
43
+ stripeAccountLivemode: snapshot.get('stripeAccountLivemode'),
44
+ agreement: snapshot.get('publisherAgreement')
45
+ };
46
+ }
47
+ /**
48
+ * Whether a user may act for a publishing org (AGL-652).
49
+ *
50
+ * Ownership checks used to read `listing.profileId === decoded.uid`. With
51
+ * org-owned listings that comparison can NEVER be true — a uid is not an org
52
+ * id — so leaving them would lock every publisher out of their own listing
53
+ * and make every paid listing chargeable to its own publisher. Managers
54
+ * (owner/admin) act for the org, matching the gate the profile rules apply.
55
+ */ export async function canActAsPublisher(firestore, uid, publisherOrgId) {
56
+ if (!uid || !publisherOrgId) return false;
57
+ const member = await firestore.collection('orgs').doc(publisherOrgId).collection('members').doc(uid).get();
58
+ const role = member.get('role');
59
+ return role === 'owner' || role === 'admin';
60
+ }
61
+ export class PublisherHandleTakenError extends Error {
62
+ constructor(handle){
63
+ super(`Handle "${handle}" is already taken`), this.handle = handle;
64
+ this.name = 'PublisherHandleTakenError';
65
+ }
66
+ }
67
+ /**
68
+ * Claim a marketplace handle for an org, transactionally (AGL-652).
69
+ *
70
+ * Mirrors the `orgSlugs` reservation: the read and the write share one
71
+ * transaction, so two orgs racing for the same handle can't both win — which
72
+ * a read-then-write check permits, and which the marketplace URL space cannot
73
+ * represent. A handle the org already holds is a no-op, so this is safe to
74
+ * call on every profile save.
75
+ *
76
+ * A renamed handle leaves a `movedTo` tombstone behind so old marketplace
77
+ * links can still resolve, and re-claiming a tombstone you own is allowed.
78
+ *
79
+ * @throws PublisherHandleTakenError when another org holds it.
80
+ */ export async function claimPublisherHandle(firestore, orgId, handle, previousHandle) {
81
+ if (!isValidPublisherHandle(handle)) {
82
+ throw new Error(`Invalid publisher handle: ${handle}`);
83
+ }
84
+ await firestore.runTransaction(async (tx)=>{
85
+ const ref = firestore.collection(PUBLISHER_HANDLES).doc(handle);
86
+ const existing = await tx.get(ref);
87
+ if (existing.exists && existing.get('orgId') !== orgId) {
88
+ throw new PublisherHandleTakenError(handle);
89
+ }
90
+ // A full set (not merge) clears any `movedTo` tombstone left by an
91
+ // earlier rename — the same reason the org-slug claim replaces wholesale.
92
+ //
93
+ // "A PREVIOUS OWNER'S rename" is what this comment used to say, and it
94
+ // cannot happen: a tombstone carries `orgId`, so the already-taken guard
95
+ // above refuses every org but the one that renamed away. A renamed handle
96
+ // is PARKED, not released, and only its original org can take it back.
97
+ //
98
+ // That was incidental until AGL-2312 and is load-bearing now: `movedTo`
99
+ // is a live redirect target on the publisher page, so if a stranger could
100
+ // claim the old handle, every link ever published to the original
101
+ // publisher would resolve to whoever grabbed it. Pinned in
102
+ // `publisher-handle-rename-tombstone.spec.ts`.
103
+ tx.set(ref, {
104
+ orgId
105
+ });
106
+ if (previousHandle && previousHandle !== handle) {
107
+ tx.set(firestore.collection(PUBLISHER_HANDLES).doc(previousHandle), {
108
+ orgId,
109
+ movedTo: handle,
110
+ renamedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp()
111
+ });
112
+ }
113
+ });
114
+ }
115
+
116
+ //# sourceMappingURL=publisher-profile.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/publisher-profile.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { type PublisherAgreementAcceptance } from '@aglyn/aglyn/app-utils/publisher-agreement'\nimport { isValidPublisherHandle } from '../model/marketplace'\n\n/** `publisherProfiles/{orgId}` — the org's marketplace identity (AGL-652). */\nexport const PUBLISHER_PROFILES = 'publisherProfiles'\n/** `publisherHandles/{handle}` — uniqueness reservations. */\nexport const PUBLISHER_HANDLES = 'publisherHandles'\n\nexport interface ResolvedPublisher {\n orgId: string\n handle: string\n displayName?: string\n stripeAccountId?: string\n stripeChargesEnabled?: boolean\n /**\n * Which Stripe world the payout account belongs to (AGL-2471).\n *\n * PROJECTED, and that is the load-bearing part. The sale gate in\n * `checkout.ts` reads this shape, not the raw document — an API projection\n * that drops a field its predicate needs turns the predicate into a\n * constant, and here the constant would be \"refuse every marketplace sale\".\n * Left three-valued deliberately: `undefined` means never established and\n * must stay distinguishable from `false`.\n */\n stripeAccountLivemode?: boolean\n /**\n * Which marketplace publisher agreement this ORG has accepted (AGL-1077).\n * Absent on every profile created before the agreement existed, which is\n * why publishing refuses rather than assuming consent.\n */\n agreement?: PublisherAgreementAcceptance\n}\n\n/**\n * Load the publishing org's marketplace profile (AGL-652).\n *\n * Publishing is org-only: the publisher is the ORG, not the person who\n * happened to click publish. Every publish route resolves through here so\n * they can't drift on which identity a listing is attributed to.\n *\n * Returns `null` when the org has no profile yet — callers turn that into a\n * 412 telling the user to set one up, matching the previous behaviour when a\n * personal profile was missing.\n */\nexport async function resolvePublisherProfile(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<ResolvedPublisher | null> {\n const snapshot = await firestore.collection(PUBLISHER_PROFILES).doc(orgId).get()\n const handle = snapshot.get('handle') as string | undefined\n if (!snapshot.exists || !handle) return null\n return {\n orgId,\n handle,\n displayName: snapshot.get('displayName') as string | undefined,\n stripeAccountId: snapshot.get('stripeAccountId') as string | undefined,\n stripeChargesEnabled: snapshot.get('stripeChargesEnabled') === true,\n // NOT coerced with `=== true` like the flag above: `false` (a test-mode\n // account) and absent (never checked) are different answers, and\n // flattening them would hide which of the two a publisher is in.\n stripeAccountLivemode: snapshot.get('stripeAccountLivemode') as\n | boolean\n | undefined,\n agreement: snapshot.get('publisherAgreement') as\n | PublisherAgreementAcceptance\n | undefined,\n }\n}\n\n/**\n * Whether a user may act for a publishing org (AGL-652).\n *\n * Ownership checks used to read `listing.profileId === decoded.uid`. With\n * org-owned listings that comparison can NEVER be true — a uid is not an org\n * id — so leaving them would lock every publisher out of their own listing\n * and make every paid listing chargeable to its own publisher. Managers\n * (owner/admin) act for the org, matching the gate the profile rules apply.\n */\nexport async function canActAsPublisher(\n firestore: FirebaseFirestore.Firestore,\n uid: string,\n publisherOrgId: string | undefined | null,\n): Promise<boolean> {\n if (!uid || !publisherOrgId) return false\n const member = await firestore\n .collection('orgs')\n .doc(publisherOrgId)\n .collection('members')\n .doc(uid)\n .get()\n const role = member.get('role')\n return role === 'owner' || role === 'admin'\n}\n\nexport class PublisherHandleTakenError extends Error {\n constructor(public readonly handle: string) {\n super(`Handle \"${handle}\" is already taken`)\n this.name = 'PublisherHandleTakenError'\n }\n}\n\n/**\n * Claim a marketplace handle for an org, transactionally (AGL-652).\n *\n * Mirrors the `orgSlugs` reservation: the read and the write share one\n * transaction, so two orgs racing for the same handle can't both win — which\n * a read-then-write check permits, and which the marketplace URL space cannot\n * represent. A handle the org already holds is a no-op, so this is safe to\n * call on every profile save.\n *\n * A renamed handle leaves a `movedTo` tombstone behind so old marketplace\n * links can still resolve, and re-claiming a tombstone you own is allowed.\n *\n * @throws PublisherHandleTakenError when another org holds it.\n */\nexport async function claimPublisherHandle(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n handle: string,\n previousHandle?: string,\n): Promise<void> {\n if (!isValidPublisherHandle(handle)) {\n throw new Error(`Invalid publisher handle: ${handle}`)\n }\n await firestore.runTransaction(async (tx) => {\n const ref = firestore.collection(PUBLISHER_HANDLES).doc(handle)\n const existing = await tx.get(ref)\n if (existing.exists && existing.get('orgId') !== orgId) {\n throw new PublisherHandleTakenError(handle)\n }\n // A full set (not merge) clears any `movedTo` tombstone left by an\n // earlier rename — the same reason the org-slug claim replaces wholesale.\n //\n // \"A PREVIOUS OWNER'S rename\" is what this comment used to say, and it\n // cannot happen: a tombstone carries `orgId`, so the already-taken guard\n // above refuses every org but the one that renamed away. A renamed handle\n // is PARKED, not released, and only its original org can take it back.\n //\n // That was incidental until AGL-2312 and is load-bearing now: `movedTo`\n // is a live redirect target on the publisher page, so if a stranger could\n // claim the old handle, every link ever published to the original\n // publisher would resolve to whoever grabbed it. Pinned in\n // `publisher-handle-rename-tombstone.spec.ts`.\n tx.set(ref, { orgId })\n if (previousHandle && previousHandle !== handle) {\n tx.set(firestore.collection(PUBLISHER_HANDLES).doc(previousHandle), {\n orgId,\n movedTo: handle,\n renamedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n })\n }\n })\n}\n"],"names":["firebaseAdmin","isValidPublisherHandle","PUBLISHER_PROFILES","PUBLISHER_HANDLES","resolvePublisherProfile","firestore","orgId","snapshot","collection","doc","get","handle","exists","displayName","stripeAccountId","stripeChargesEnabled","stripeAccountLivemode","agreement","canActAsPublisher","uid","publisherOrgId","member","role","PublisherHandleTakenError","Error","name","claimPublisherHandle","previousHandle","runTransaction","tx","ref","existing","set","movedTo","renamedAt","FieldValue","serverTimestamp"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,aAAa,QAAQ,2BAA0B;AAExD,SAASC,sBAAsB,QAAQ,0BAAsB;AAE7D,4EAA4E,GAC5E,OAAO,MAAMC,qBAAqB,oBAAmB;AACrD,2DAA2D,GAC3D,OAAO,MAAMC,oBAAoB,mBAAkB;AA2BnD;;;;;;;;;;CAUC,GACD,OAAO,eAAeC,wBACpBC,SAAsC,EACtCC,KAAa;IAEb,MAAMC,WAAW,MAAMF,UAAUG,UAAU,CAACN,oBAAoBO,GAAG,CAACH,OAAOI,GAAG;IAC9E,MAAMC,SAASJ,SAASG,GAAG,CAAC;IAC5B,IAAI,CAACH,SAASK,MAAM,IAAI,CAACD,QAAQ,OAAO;IACxC,OAAO;QACLL;QACAK;QACAE,aAAaN,SAASG,GAAG,CAAC;QAC1BI,iBAAiBP,SAASG,GAAG,CAAC;QAC9BK,sBAAsBR,SAASG,GAAG,CAAC,4BAA4B;QAC/D,wEAAwE;QACxE,iEAAiE;QACjE,iEAAiE;QACjEM,uBAAuBT,SAASG,GAAG,CAAC;QAGpCO,WAAWV,SAASG,GAAG,CAAC;IAG1B;AACF;AAEA;;;;;;;;CAQC,GACD,OAAO,eAAeQ,kBACpBb,SAAsC,EACtCc,GAAW,EACXC,cAAyC;IAEzC,IAAI,CAACD,OAAO,CAACC,gBAAgB,OAAO;IACpC,MAAMC,SAAS,MAAMhB,UAClBG,UAAU,CAAC,QACXC,GAAG,CAACW,gBACJZ,UAAU,CAAC,WACXC,GAAG,CAACU,KACJT,GAAG;IACN,MAAMY,OAAOD,OAAOX,GAAG,CAAC;IACxB,OAAOY,SAAS,WAAWA,SAAS;AACtC;AAEA,OAAO,MAAMC,kCAAkCC;IAC7C,YAAY,AAAgBb,MAAc,CAAE;QAC1C,KAAK,CAAC,CAAC,QAAQ,EAAEA,OAAO,kBAAkB,CAAC,QADjBA,SAAAA;QAE1B,IAAI,CAACc,IAAI,GAAG;IACd;AACF;AAEA;;;;;;;;;;;;;CAaC,GACD,OAAO,eAAeC,qBACpBrB,SAAsC,EACtCC,KAAa,EACbK,MAAc,EACdgB,cAAuB;IAEvB,IAAI,CAAC1B,uBAAuBU,SAAS;QACnC,MAAM,IAAIa,MAAM,CAAC,0BAA0B,EAAEb,QAAQ;IACvD;IACA,MAAMN,UAAUuB,cAAc,CAAC,OAAOC;QACpC,MAAMC,MAAMzB,UAAUG,UAAU,CAACL,mBAAmBM,GAAG,CAACE;QACxD,MAAMoB,WAAW,MAAMF,GAAGnB,GAAG,CAACoB;QAC9B,IAAIC,SAASnB,MAAM,IAAImB,SAASrB,GAAG,CAAC,aAAaJ,OAAO;YACtD,MAAM,IAAIiB,0BAA0BZ;QACtC;QACA,mEAAmE;QACnE,0EAA0E;QAC1E,EAAE;QACF,uEAAuE;QACvE,yEAAyE;QACzE,0EAA0E;QAC1E,uEAAuE;QACvE,EAAE;QACF,wEAAwE;QACxE,0EAA0E;QAC1E,kEAAkE;QAClE,2DAA2D;QAC3D,+CAA+C;QAC/CkB,GAAGG,GAAG,CAACF,KAAK;YAAExB;QAAM;QACpB,IAAIqB,kBAAkBA,mBAAmBhB,QAAQ;YAC/CkB,GAAGG,GAAG,CAAC3B,UAAUG,UAAU,CAACL,mBAAmBM,GAAG,CAACkB,iBAAiB;gBAClErB;gBACA2B,SAAStB;gBACTuB,WAAWlC,cAAcK,SAAS,CAAC8B,UAAU,CAACC,eAAe;YAC/D;QACF;IACF;AACF"}
@@ -0,0 +1,83 @@
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
+ interface LivePurchaseQuery {
18
+ firestore: FirebaseFirestore.Firestore;
19
+ /** The authenticated caller's uid — never a value from the request body. */
20
+ buyerUid: string;
21
+ /**
22
+ * THE ORGANIZATION BEING INSTALLED INTO (AGL-2331) — required, because a
23
+ * licence belongs to an org and asking this question without one can only be
24
+ * answered by the legacy grant.
25
+ *
26
+ * Every caller resolves it SERVER-SIDE from an authenticated membership
27
+ * (`resolveOrgPermissions`), never from a request body field. It is
28
+ * deliberately not optional: the gate that decides who gets paid content
29
+ * must not be satisfiable by forgetting an argument, and a required property
30
+ * makes every one of the nine doors a compile error until it is wired.
31
+ *
32
+ * The empty string is legal and means "this site has no owning org" — see
33
+ * `purchaseEntitlesOrg`, which never matches a document against it.
34
+ */
35
+ buyerOrgId: string;
36
+ listingId: string;
37
+ }
38
+ /**
39
+ * True when this ORGANIZATION holds a live licence for this listing
40
+ * (AGL-2331); see `purchaseEntitlesOrg` in the model for the licensing model
41
+ * and the legacy grant, which is where the reasoning lives.
42
+ *
43
+ * A FULLY refunded purchase (`refundedAt`, stamped by the `charge.refunded`
44
+ * branch of the marketplace billing webhook) reads as absent. Re-buying after a
45
+ * refund writes a fresh session-keyed doc, so a legitimate second purchase
46
+ * still installs and a refunded one sitting beside it does not veto it.
47
+ *
48
+ * TWO queries, not one, and not an `in` filter over both fields — Firestore
49
+ * has no disjunction across different fields that also keeps the `listingId`
50
+ * equality, and the honest question genuinely is a union of two populations:
51
+ * the org's licences, and this person's pre-AGL-2331 purchases. Both are
52
+ * equality-only conjunctions, so both are served by the automatic single-field
53
+ * indexes exactly as the original query was — no composite index, nothing to
54
+ * deploy.
55
+ *
56
+ * The org query is skipped entirely when there is no org, and the legacy query
57
+ * when there is no uid, so the common path (an org-stamped licence for a
58
+ * signed-in member) still costs the two reads it always did plus one for the
59
+ * grandfather — and the grandfather query is against a set that can only
60
+ * shrink.
61
+ *
62
+ * Callers gate on this only when `priceUsd > 0` and the caller is not the
63
+ * publisher — a free listing and a publisher installing their own work never
64
+ * had a purchase doc to find.
65
+ */
66
+ export declare function hasLivePurchase({ firestore, buyerUid, buyerOrgId, listingId, }: LivePurchaseQuery): Promise<boolean>;
67
+ /**
68
+ * The whole gate: resolves to `null` when the caller may proceed, or to the
69
+ * 402 payload to return when they may not.
70
+ *
71
+ * Returning the payload rather than writing the response keeps this usable
72
+ * from `update-artifact`, whose preview and apply branches answer differently,
73
+ * while still giving the seven install routes a single-line gate.
74
+ */
75
+ export declare function requirePurchase({ firestore, buyerUid, buyerOrgId, listingId, priceUsd, ownsListing, }: LivePurchaseQuery & {
76
+ priceUsd: number;
77
+ /** The publisher installs their own listing for free (AGL-652). */
78
+ ownsListing: boolean;
79
+ }): Promise<{
80
+ error: string;
81
+ priceUsd: number;
82
+ } | null>;
83
+ export {};