@atproto/bsky 0.0.233 → 0.0.235
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/dist/api/app/bsky/embed/getEmbedExternalView.js +5 -3
- package/dist/api/app/bsky/embed/getEmbedExternalView.js.map +1 -1
- package/dist/hydration/external.d.ts.map +1 -1
- package/dist/hydration/external.js +8 -0
- package/dist/hydration/external.js.map +1 -1
- package/dist/hydration/hydrator.d.ts.map +1 -1
- package/dist/hydration/hydrator.js +48 -5
- package/dist/hydration/hydrator.js.map +1 -1
- package/dist/lexicons/app/bsky/embed/external.defs.d.ts +5 -0
- package/dist/lexicons/app/bsky/embed/external.defs.d.ts.map +1 -1
- package/dist/lexicons/app/bsky/embed/external.defs.js +4 -0
- package/dist/lexicons/app/bsky/embed/external.defs.js.map +1 -1
- package/dist/lexicons/chat/bsky/actor/getStatus.defs.d.ts +2 -0
- package/dist/lexicons/chat/bsky/actor/getStatus.defs.d.ts.map +1 -1
- package/dist/lexicons/chat/bsky/actor/getStatus.defs.js +1 -0
- package/dist/lexicons/chat/bsky/actor/getStatus.defs.js.map +1 -1
- package/dist/lexicons/chat/bsky/authFullChatClient.defs.js +1 -1
- package/dist/lexicons/chat/bsky/authFullChatClient.defs.js.map +1 -1
- package/dist/lexicons/chat/bsky/convo/defs.defs.d.ts +8 -0
- package/dist/lexicons/chat/bsky/convo/defs.defs.d.ts.map +1 -1
- package/dist/lexicons/chat/bsky/convo/defs.defs.js +2 -0
- package/dist/lexicons/chat/bsky/convo/defs.defs.js.map +1 -1
- package/dist/lexicons/chat/bsky/convo/listConvoRequests.defs.d.ts +3 -3
- package/dist/lexicons/chat/bsky/convo/listConvoRequests.defs.d.ts.map +1 -1
- package/dist/lexicons/chat/bsky/convo/listConvoRequests.defs.js +2 -2
- package/dist/lexicons/chat/bsky/convo/listConvoRequests.defs.js.map +1 -1
- package/dist/lexicons/chat/bsky/group/defs.defs.d.ts +26 -0
- package/dist/lexicons/chat/bsky/group/defs.defs.d.ts.map +1 -1
- package/dist/lexicons/chat/bsky/group/defs.defs.js +23 -0
- package/dist/lexicons/chat/bsky/group/defs.defs.js.map +1 -1
- package/dist/lexicons/chat/bsky/group/getJoinLinkPreviews.d.ts +3 -0
- package/dist/lexicons/chat/bsky/group/getJoinLinkPreviews.d.ts.map +1 -0
- package/dist/lexicons/chat/bsky/group/getJoinLinkPreviews.defs.d.ts +22 -0
- package/dist/lexicons/chat/bsky/group/getJoinLinkPreviews.defs.d.ts.map +1 -0
- package/dist/lexicons/chat/bsky/group/getJoinLinkPreviews.defs.js +22 -0
- package/dist/lexicons/chat/bsky/group/getJoinLinkPreviews.defs.js.map +1 -0
- package/dist/lexicons/chat/bsky/group/getJoinLinkPreviews.js +6 -0
- package/dist/lexicons/chat/bsky/group/getJoinLinkPreviews.js.map +1 -0
- package/dist/lexicons/chat/bsky/group.d.ts +1 -1
- package/dist/lexicons/chat/bsky/group.d.ts.map +1 -1
- package/dist/lexicons/chat/bsky/group.js +1 -1
- package/dist/lexicons/chat/bsky/group.js.map +1 -1
- package/dist/lexicons/com/atproto/server/getServiceAuth.defs.d.ts +2 -2
- package/dist/lexicons/com/atproto/server/getServiceAuth.defs.js +1 -1
- package/dist/lexicons/com/atproto/server/getServiceAuth.defs.js.map +1 -1
- package/dist/logger.d.ts +1 -0
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +1 -0
- package/dist/logger.js.map +1 -1
- package/dist/util/standard-site.d.ts +12 -5
- package/dist/util/standard-site.d.ts.map +1 -1
- package/dist/util/standard-site.js +30 -9
- package/dist/util/standard-site.js.map +1 -1
- package/dist/views/index.d.ts.map +1 -1
- package/dist/views/index.js +14 -1
- package/dist/views/index.js.map +1 -1
- package/package.json +5 -5
- package/src/api/app/bsky/embed/getEmbedExternalView.ts +5 -3
- package/src/hydration/external.ts +17 -0
- package/src/hydration/hydrator.ts +53 -5
- package/src/logger.ts +2 -0
- package/src/util/standard-site.test.ts +116 -24
- package/src/util/standard-site.ts +30 -11
- package/src/views/index.ts +24 -1
- package/tests/views/profile.test.ts +3 -10
- package/tsconfig.build.tsbuildinfo +1 -1
- package/dist/lexicons/chat/bsky/group/getJoinLinkPreview.d.ts +0 -3
- package/dist/lexicons/chat/bsky/group/getJoinLinkPreview.d.ts.map +0 -1
- package/dist/lexicons/chat/bsky/group/getJoinLinkPreview.defs.d.ts +0 -22
- package/dist/lexicons/chat/bsky/group/getJoinLinkPreview.defs.d.ts.map +0 -1
- package/dist/lexicons/chat/bsky/group/getJoinLinkPreview.defs.js +0 -18
- package/dist/lexicons/chat/bsky/group/getJoinLinkPreview.defs.js.map +0 -1
- package/dist/lexicons/chat/bsky/group/getJoinLinkPreview.js +0 -6
- package/dist/lexicons/chat/bsky/group/getJoinLinkPreview.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atproto/bsky",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.235",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Reference implementation of app.bsky App View (Bluesky API)",
|
|
6
6
|
"keywords": [
|
|
@@ -50,12 +50,12 @@
|
|
|
50
50
|
"zod": "3.23.8",
|
|
51
51
|
"@atproto-labs/fetch-node": "^0.3.0",
|
|
52
52
|
"@atproto-labs/xrpc-utils": "^0.1.0",
|
|
53
|
-
"@atproto/api": "^0.20.
|
|
53
|
+
"@atproto/api": "^0.20.6",
|
|
54
54
|
"@atproto/common": "^0.6.1",
|
|
55
55
|
"@atproto/crypto": "^0.5.0",
|
|
56
|
-
"@atproto/did": "^0.
|
|
56
|
+
"@atproto/did": "^0.5.0",
|
|
57
57
|
"@atproto/identity": "^0.5.0",
|
|
58
|
-
"@atproto/lex": "^0.1.
|
|
58
|
+
"@atproto/lex": "^0.1.3",
|
|
59
59
|
"@atproto/repo": "^0.10.0",
|
|
60
60
|
"@atproto/sync": "^0.3.1",
|
|
61
61
|
"@atproto/syntax": "^0.6.1",
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
"ts-node": "^10.8.2",
|
|
75
75
|
"typescript": "^6.0.3",
|
|
76
76
|
"vitest": "^4.0.16",
|
|
77
|
-
"@atproto/pds": "^0.
|
|
77
|
+
"@atproto/pds": "^0.5.1"
|
|
78
78
|
},
|
|
79
79
|
"type": "module",
|
|
80
80
|
"exports": {
|
|
@@ -61,8 +61,9 @@ const hydration = async (
|
|
|
61
61
|
const presentation = (
|
|
62
62
|
inputs: PresentationFnInput<Context, Params, Skeleton>,
|
|
63
63
|
): Output => {
|
|
64
|
-
const
|
|
65
|
-
const
|
|
64
|
+
const { hydration } = inputs
|
|
65
|
+
const documents = hydration.siteStandardDocuments
|
|
66
|
+
const publications = hydration.siteStandardPublications
|
|
66
67
|
// Dispatch by record type. Today site.standard is the only kind we know
|
|
67
68
|
// how to render; future record types get their own branch.
|
|
68
69
|
if (
|
|
@@ -93,7 +94,8 @@ const standardSitePresentation = (
|
|
|
93
94
|
// Emit response refs/records only for the records we actually selected.
|
|
94
95
|
// Anything else (e.g. extra publications the dataplane returned) is
|
|
95
96
|
// intentionally excluded so the strongRefs Cardy writes onto the post
|
|
96
|
-
// match the view we built.
|
|
97
|
+
// match the view we built. Profiles are emitted in the same order as
|
|
98
|
+
// refs (one per slot) so consumers can match by index.
|
|
97
99
|
const associatedRefs: StrongRef[] = []
|
|
98
100
|
const associatedRecords: LexMap[] = []
|
|
99
101
|
for (const slot of [document, publication]) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { AtUriString } from '@atproto/syntax'
|
|
2
2
|
import { DataPlaneClient } from '../data-plane/client/index.js'
|
|
3
3
|
import { site } from '../lexicons/index.js'
|
|
4
|
+
import { hydrationLogger } from '../logger.js'
|
|
4
5
|
import {
|
|
5
6
|
GetSiteStandardRecordsByRefResponse,
|
|
6
7
|
GetSiteStandardRecordsByURIResponse,
|
|
@@ -162,6 +163,14 @@ export const getSiteStandardRecordsFromHydrationMapsByRefs = (
|
|
|
162
163
|
// (or tampered with), so reject the whole pairing.
|
|
163
164
|
if (document && publication) {
|
|
164
165
|
if (document.info.record.site !== publication.ref.uri) {
|
|
166
|
+
hydrationLogger.warn(
|
|
167
|
+
{
|
|
168
|
+
documentUri: document.ref.uri,
|
|
169
|
+
documentSite: document.info.record.site,
|
|
170
|
+
publicationUri: publication.ref.uri,
|
|
171
|
+
},
|
|
172
|
+
'site.standard byRefs lookup failed: doc.site does not match hydrated publication.uri',
|
|
173
|
+
)
|
|
165
174
|
return { document: undefined, publication: undefined }
|
|
166
175
|
}
|
|
167
176
|
}
|
|
@@ -172,6 +181,10 @@ export const getSiteStandardRecordsFromHydrationMapsByRefs = (
|
|
|
172
181
|
if (document && !publication) {
|
|
173
182
|
const site = document.info.record.site
|
|
174
183
|
if (site && site.startsWith('at://')) {
|
|
184
|
+
hydrationLogger.warn(
|
|
185
|
+
{ documentUri: document.ref.uri, documentSite: site },
|
|
186
|
+
'site.standard byRefs lookup failed: document.site is AT URI but no matching publication was hydrated',
|
|
187
|
+
)
|
|
175
188
|
return { document: undefined, publication: undefined }
|
|
176
189
|
}
|
|
177
190
|
}
|
|
@@ -234,6 +247,10 @@ export const getSiteStandardRecordsFromHydrationMapsByDocumentUri = (
|
|
|
234
247
|
}
|
|
235
248
|
}
|
|
236
249
|
if (!publication) {
|
|
250
|
+
hydrationLogger.warn(
|
|
251
|
+
{ documentUri: document.ref.uri, documentSite: site },
|
|
252
|
+
'site.standard byDocumentUri lookup failed: document.site is AT URI but no matching publication was hydrated',
|
|
253
|
+
)
|
|
237
254
|
return { document: undefined, publication: undefined }
|
|
238
255
|
}
|
|
239
256
|
}
|
|
@@ -612,6 +612,15 @@ export class Hydrator {
|
|
|
612
612
|
const siteStandardLabelSubjects = dedupeStrs(
|
|
613
613
|
siteStandardRefs.map((ref) => ref.uri),
|
|
614
614
|
)
|
|
615
|
+
// Site-standard record owners need profile basics so we can populate
|
|
616
|
+
// `associatedProfiles` on the external embed view. Include them in the
|
|
617
|
+
// post-author profile fetch below; any DIDs surfaced later by the
|
|
618
|
+
// dataplane are picked up by the top-up after the parallel batch.
|
|
619
|
+
const ssRefDids = siteStandardRefs.map((ref) => uriToDid(ref.uri))
|
|
620
|
+
const knownProfileDids = dedupeStrs([
|
|
621
|
+
...allPostUris.map(didFromUri),
|
|
622
|
+
...ssRefDids,
|
|
623
|
+
])
|
|
615
624
|
|
|
616
625
|
const [
|
|
617
626
|
postAggs,
|
|
@@ -638,7 +647,7 @@ export class Hydrator {
|
|
|
638
647
|
ctx.labelers,
|
|
639
648
|
),
|
|
640
649
|
this.hydratePostBlocks(posts, ctx),
|
|
641
|
-
this.hydrateProfiles(
|
|
650
|
+
this.hydrateProfiles(knownProfileDids, ctx),
|
|
642
651
|
this.hydrateLists([...nestedListUris, ...threadgateListUris], ctx),
|
|
643
652
|
this.hydrateFeedGens(nestedFeedGenUris, ctx),
|
|
644
653
|
this.hydrateLabelers(nestedLabelerDids, ctx),
|
|
@@ -657,9 +666,28 @@ export class Hydrator {
|
|
|
657
666
|
labels,
|
|
658
667
|
)
|
|
659
668
|
}
|
|
669
|
+
|
|
670
|
+
// Defensive top-up: in the unlikely case the dataplane returned a
|
|
671
|
+
// publication owned by a DID not present in the post-author or
|
|
672
|
+
// pinned-ref sets, fetch its profile serially and fold it in.
|
|
673
|
+
const knownProfileDidsSet = new Set(knownProfileDids)
|
|
674
|
+
const extraSsDids: DidString[] = []
|
|
675
|
+
for (const key of siteStandardPublications.keys()) {
|
|
676
|
+
const did = uriToDid(parseSiteStandardRecordKey(key).uri)
|
|
677
|
+
if (!knownProfileDidsSet.has(did)) {
|
|
678
|
+
knownProfileDidsSet.add(did)
|
|
679
|
+
extraSsDids.push(did)
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
const mergedProfileState = extraSsDids.length
|
|
683
|
+
? mergeStates(
|
|
684
|
+
profileState,
|
|
685
|
+
await this.hydrateProfilesBasic(extraSsDids, ctx),
|
|
686
|
+
)
|
|
687
|
+
: profileState
|
|
660
688
|
// combine all hydration state
|
|
661
689
|
return mergeManyStates(
|
|
662
|
-
|
|
690
|
+
mergedProfileState,
|
|
663
691
|
listState,
|
|
664
692
|
feedGenState,
|
|
665
693
|
labelerState,
|
|
@@ -810,20 +838,40 @@ export class Hydrator {
|
|
|
810
838
|
),
|
|
811
839
|
) as AtUriString[]
|
|
812
840
|
if (!ssUris.length) return { ctx }
|
|
841
|
+
const dids = dedupeStrs(ssUris.map((uri) => uriToDid(uri)))
|
|
813
842
|
|
|
814
|
-
const [{ documents, publications }, labels] = await Promise.all([
|
|
843
|
+
const [{ documents, publications }, labels, profiles] = await Promise.all([
|
|
815
844
|
this.external.getSiteStandardRecordsByURI(ssUris, ctx.includeTakedowns),
|
|
816
845
|
this.label.getLabelsForSubjects(ssUris, ctx.labelers),
|
|
846
|
+
this.hydrateProfilesBasic(dids, ctx),
|
|
817
847
|
])
|
|
818
848
|
if (!ctx.includeTakedowns) {
|
|
819
849
|
actionSiteStandardTakedownLabels(documents, publications, labels)
|
|
820
850
|
}
|
|
821
|
-
|
|
851
|
+
// Edge case: a document's `site` may resolve to a publication owned by a
|
|
852
|
+
// different repo than any of the input URIs (the dataplane returns it
|
|
853
|
+
// even though it wasn't requested directly). Top up profile coverage for
|
|
854
|
+
// any such DIDs with a serial second hydration so `associatedProfiles`
|
|
855
|
+
// is complete.
|
|
856
|
+
const knownDids = new Set<string>(dids)
|
|
857
|
+
const extraDids: DidString[] = []
|
|
858
|
+
for (const key of publications.keys()) {
|
|
859
|
+
const did = uriToDid(parseSiteStandardRecordKey(key).uri)
|
|
860
|
+
if (!knownDids.has(did)) {
|
|
861
|
+
knownDids.add(did)
|
|
862
|
+
extraDids.push(did)
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
const profilesState = extraDids.length
|
|
866
|
+
? mergeStates(profiles, await this.hydrateProfilesBasic(extraDids, ctx))
|
|
867
|
+
: profiles
|
|
868
|
+
|
|
869
|
+
return mergeStates(profilesState, {
|
|
822
870
|
ctx,
|
|
823
871
|
labels,
|
|
824
872
|
siteStandardDocuments: documents,
|
|
825
873
|
siteStandardPublications: publications,
|
|
826
|
-
}
|
|
874
|
+
})
|
|
827
875
|
}
|
|
828
876
|
|
|
829
877
|
// app.bsky.feed.defs#threadViewPost
|
package/src/logger.ts
CHANGED
|
@@ -19,6 +19,8 @@ export const dataplaneLogger: ReturnType<typeof subsystemLogger> =
|
|
|
19
19
|
subsystemLogger('bsky:dp')
|
|
20
20
|
export const ageAssuranceLogger: ReturnType<typeof subsystemLogger> =
|
|
21
21
|
subsystemLogger('bsky:aa')
|
|
22
|
+
export const viewsLogger: ReturnType<typeof subsystemLogger> =
|
|
23
|
+
subsystemLogger('bsky:views')
|
|
22
24
|
export const httpLogger: ReturnType<typeof subsystemLogger> =
|
|
23
25
|
subsystemLogger('bsky')
|
|
24
26
|
|
|
@@ -101,6 +101,13 @@ describe(validateStandardSiteForUrl, () => {
|
|
|
101
101
|
validateStandardSiteForUrl(undefined, pub, 'https://other.com'),
|
|
102
102
|
).toBe(false)
|
|
103
103
|
})
|
|
104
|
+
|
|
105
|
+
it('accepts when assumedUrl has a trailing slash and publication.url does not', () => {
|
|
106
|
+
const pub = makePub({ url: 'https://atproto.com/blog' })
|
|
107
|
+
expect(
|
|
108
|
+
validateStandardSiteForUrl(undefined, pub, 'https://atproto.com/blog/'),
|
|
109
|
+
).toBe(true)
|
|
110
|
+
})
|
|
104
111
|
})
|
|
105
112
|
|
|
106
113
|
describe('neither', () => {
|
|
@@ -158,29 +165,114 @@ describe(validateStandardSiteForUrl, () => {
|
|
|
158
165
|
}
|
|
159
166
|
})
|
|
160
167
|
|
|
161
|
-
describe('
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
168
|
+
describe('path joining (root domain × subpath × slash placement)', () => {
|
|
169
|
+
// Real-world example that originally broke:
|
|
170
|
+
// pub.url: 'https://atproto.com/blog'
|
|
171
|
+
// doc.path: '/indexing-standard-site'
|
|
172
|
+
// expected: 'https://atproto.com/blog/indexing-standard-site'
|
|
173
|
+
// `new URL('/indexing-standard-site', 'https://atproto.com/blog')` would
|
|
174
|
+
// resolve to 'https://atproto.com/indexing-standard-site' (the leading
|
|
175
|
+
// slash on path swallows the base's pathname under WHATWG semantics) —
|
|
176
|
+
// we want path-append, not relative resolution.
|
|
177
|
+
for (const { note, baseUrl, path, assumedUrl, expected } of [
|
|
178
|
+
// Root-domain base, all four slash combinations.
|
|
179
|
+
{
|
|
180
|
+
note: 'root base, base/, path/',
|
|
181
|
+
baseUrl: 'https://example.com/',
|
|
182
|
+
path: '/posts/hello',
|
|
183
|
+
assumedUrl: 'https://example.com/posts/hello',
|
|
184
|
+
expected: true,
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
note: 'root base, base/, path-no-slash',
|
|
188
|
+
baseUrl: 'https://example.com/',
|
|
189
|
+
path: 'posts/hello',
|
|
190
|
+
assumedUrl: 'https://example.com/posts/hello',
|
|
191
|
+
expected: true,
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
note: 'root base, base-no-slash, path/',
|
|
195
|
+
baseUrl: 'https://example.com',
|
|
196
|
+
path: '/posts/hello',
|
|
197
|
+
assumedUrl: 'https://example.com/posts/hello',
|
|
198
|
+
expected: true,
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
note: 'root base, base-no-slash, path-no-slash',
|
|
202
|
+
baseUrl: 'https://example.com',
|
|
203
|
+
path: 'posts/hello',
|
|
204
|
+
assumedUrl: 'https://example.com/posts/hello',
|
|
205
|
+
expected: true,
|
|
206
|
+
},
|
|
207
|
+
// Subpath base, all four slash combinations (the regression case).
|
|
208
|
+
{
|
|
209
|
+
note: 'subpath base, base/, path/',
|
|
210
|
+
baseUrl: 'https://atproto.com/blog/',
|
|
211
|
+
path: '/indexing-standard-site',
|
|
212
|
+
assumedUrl: 'https://atproto.com/blog/indexing-standard-site',
|
|
213
|
+
expected: true,
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
note: 'subpath base, base/, path-no-slash',
|
|
217
|
+
baseUrl: 'https://atproto.com/blog/',
|
|
218
|
+
path: 'indexing-standard-site',
|
|
219
|
+
assumedUrl: 'https://atproto.com/blog/indexing-standard-site',
|
|
220
|
+
expected: true,
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
note: 'subpath base, base-no-slash, path/ (regression)',
|
|
224
|
+
baseUrl: 'https://atproto.com/blog',
|
|
225
|
+
path: '/indexing-standard-site',
|
|
226
|
+
assumedUrl: 'https://atproto.com/blog/indexing-standard-site',
|
|
227
|
+
expected: true,
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
note: 'subpath base, base-no-slash, path-no-slash',
|
|
231
|
+
baseUrl: 'https://atproto.com/blog',
|
|
232
|
+
path: 'indexing-standard-site',
|
|
233
|
+
assumedUrl: 'https://atproto.com/blog/indexing-standard-site',
|
|
234
|
+
expected: true,
|
|
235
|
+
},
|
|
236
|
+
// Empty path: assumedUrl should equal the base.
|
|
237
|
+
{
|
|
238
|
+
note: 'empty path, root base, no trailing slash',
|
|
239
|
+
baseUrl: 'https://example.com',
|
|
240
|
+
path: undefined,
|
|
241
|
+
assumedUrl: 'https://example.com',
|
|
242
|
+
expected: true,
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
note: 'empty path, subpath base, with trailing slash',
|
|
246
|
+
baseUrl: 'https://atproto.com/blog/',
|
|
247
|
+
path: undefined,
|
|
248
|
+
assumedUrl: 'https://atproto.com/blog',
|
|
249
|
+
expected: true,
|
|
250
|
+
},
|
|
251
|
+
// Negative: subpath base with assumedUrl that lost the subpath
|
|
252
|
+
// (what `new URL`'s relative resolution would have produced).
|
|
253
|
+
{
|
|
254
|
+
note: 'rejects when assumed URL drops the subpath',
|
|
255
|
+
baseUrl: 'https://atproto.com/blog',
|
|
256
|
+
path: '/indexing-standard-site',
|
|
257
|
+
assumedUrl: 'https://atproto.com/indexing-standard-site',
|
|
258
|
+
expected: false,
|
|
259
|
+
},
|
|
260
|
+
]) {
|
|
261
|
+
it(`doc + pub: ${note}`, () => {
|
|
262
|
+
const doc = makeDoc(
|
|
263
|
+
path === undefined ? { site: pubUri } : { site: pubUri, path },
|
|
264
|
+
)
|
|
265
|
+
const pub = makePub({ url: baseUrl })
|
|
266
|
+
expect(validateStandardSiteForUrl(doc, pub, assumedUrl)).toBe(expected)
|
|
267
|
+
})
|
|
268
|
+
it(`loose doc: ${note}`, () => {
|
|
269
|
+
const doc = makeDoc(
|
|
270
|
+
path === undefined ? { site: baseUrl } : { site: baseUrl, path },
|
|
271
|
+
)
|
|
272
|
+
expect(validateStandardSiteForUrl(doc, undefined, assumedUrl)).toBe(
|
|
273
|
+
expected,
|
|
274
|
+
)
|
|
275
|
+
})
|
|
276
|
+
}
|
|
185
277
|
})
|
|
186
278
|
})
|
|
@@ -27,10 +27,10 @@ export const parseSiteStandardRecordKey = (
|
|
|
27
27
|
* strips a trailing slash from the path, and drops query/fragment. Returns
|
|
28
28
|
* `null` when the input isn't a valid HTTP(S) URL.
|
|
29
29
|
*/
|
|
30
|
-
const canonicalizeHttpUrl = (url: string
|
|
30
|
+
const canonicalizeHttpUrl = (url: string): string | null => {
|
|
31
31
|
let parsed: URL
|
|
32
32
|
try {
|
|
33
|
-
parsed = new URL(url
|
|
33
|
+
parsed = new URL(url)
|
|
34
34
|
} catch {
|
|
35
35
|
return null
|
|
36
36
|
}
|
|
@@ -40,11 +40,32 @@ const canonicalizeHttpUrl = (url: string, base?: string): string | null => {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
43
|
+
* Append `path` to `base` with exactly one slash between, or return `base`
|
|
44
|
+
* unchanged when `path` is empty. Unlike `new URL(path, base)`, a leading
|
|
45
|
+
* slash on `path` does NOT swallow `base`'s pathname — so
|
|
46
|
+
* `joinPath('https://x.com/blog', '/foo')` is `https://x.com/blog/foo`,
|
|
47
|
+
* not `https://x.com/foo`.
|
|
48
|
+
*/
|
|
49
|
+
const joinPath = (base: string, path: string): string => {
|
|
50
|
+
if (!path) return base
|
|
51
|
+
const baseTrimmed = base.endsWith('/') ? base.slice(0, -1) : base
|
|
52
|
+
const pathTrimmed = path.startsWith('/') ? path.slice(1) : path
|
|
53
|
+
return `${baseTrimmed}/${pathTrimmed}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Confirm that the supplied SS records actually back `assumedUrl`. The
|
|
58
|
+
* record-side URL is built by concatenating the publication URL (or the
|
|
59
|
+
* loose-doc site) with the document's `path` field, then both sides are
|
|
60
|
+
* canonicalized for equality: lowercase host, query/fragment dropped,
|
|
61
|
+
* trailing slash stripped.
|
|
62
|
+
*
|
|
63
|
+
* Path concatenation is `base + '/' + path` semantics — a leading `/` on
|
|
64
|
+
* `path` does NOT swallow the base's pathname (the way
|
|
65
|
+
* `new URL(path, base)` would). So
|
|
66
|
+
* `'https://atproto.com/blog' + '/indexing-standard-site'` resolves to
|
|
67
|
+
* `https://atproto.com/blog/indexing-standard-site` regardless of which
|
|
68
|
+
* side carries the slash.
|
|
48
69
|
*
|
|
49
70
|
* Structural validation of the doc/pub pair (matching `site` ↔ pub URI,
|
|
50
71
|
* no orphan docs that claim a missing publication) happens upstream in
|
|
@@ -81,15 +102,13 @@ export const validateStandardSiteForUrl = (
|
|
|
81
102
|
|
|
82
103
|
if (document && publication) {
|
|
83
104
|
const joined = canonicalizeHttpUrl(
|
|
84
|
-
document.info.record.path ?? '',
|
|
85
|
-
publication.info.record.url,
|
|
105
|
+
joinPath(publication.info.record.url, document.info.record.path ?? ''),
|
|
86
106
|
)
|
|
87
107
|
return joined === canonicalAssumed
|
|
88
108
|
}
|
|
89
109
|
if (document) {
|
|
90
110
|
const joined = canonicalizeHttpUrl(
|
|
91
|
-
document.info.record.path ?? '',
|
|
92
|
-
document.info.record.site,
|
|
111
|
+
joinPath(document.info.record.site, document.info.record.path ?? ''),
|
|
93
112
|
)
|
|
94
113
|
return joined === canonicalAssumed
|
|
95
114
|
}
|
package/src/views/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { HOUR, MINUTE, mapDefined } from '@atproto/common'
|
|
1
|
+
import { HOUR, MINUTE, dedupeStrs, mapDefined } from '@atproto/common'
|
|
2
2
|
import {
|
|
3
3
|
$Typed,
|
|
4
4
|
Un$Typed,
|
|
@@ -28,6 +28,7 @@ import { Label } from '../hydration/label.js'
|
|
|
28
28
|
import { RecordInfo, parseString } from '../hydration/util.js'
|
|
29
29
|
import { ImageUriBuilder } from '../image/uri.js'
|
|
30
30
|
import { app, site } from '../lexicons/index.js'
|
|
31
|
+
import { viewsLogger } from '../logger.js'
|
|
31
32
|
import { Notification } from '../proto/bsky_pb.js'
|
|
32
33
|
import {
|
|
33
34
|
estimateReadingTimeMinutes,
|
|
@@ -2246,6 +2247,13 @@ export class Views {
|
|
|
2246
2247
|
// bare embed rather than render partial / disagreeing enrichment.
|
|
2247
2248
|
if (!document && !publication) return undefined
|
|
2248
2249
|
if (!validateStandardSiteForUrl(document, publication, assumedUrl)) {
|
|
2250
|
+
viewsLogger.warn(
|
|
2251
|
+
{
|
|
2252
|
+
documentUri: document?.ref.uri,
|
|
2253
|
+
publicationUri: publication?.ref.uri,
|
|
2254
|
+
},
|
|
2255
|
+
'site.standard URL validation failed',
|
|
2256
|
+
)
|
|
2249
2257
|
return undefined
|
|
2250
2258
|
}
|
|
2251
2259
|
|
|
@@ -2314,6 +2322,21 @@ export class Views {
|
|
|
2314
2322
|
overlay.source = this.externalEmbedSource(publication)
|
|
2315
2323
|
}
|
|
2316
2324
|
|
|
2325
|
+
// Profiles of the owners of the records backing this embed. Hydrator
|
|
2326
|
+
// covers these DIDs alongside post-author profiles, so misses here
|
|
2327
|
+
// only happen when an actor is unavailable (suspended, deleted, etc.)
|
|
2328
|
+
// — drop those rather than emit `undefined` slots.
|
|
2329
|
+
const uniqueDids = dedupeStrs(
|
|
2330
|
+
mapDefined([document?.ref.uri, publication?.ref.uri], (uri) =>
|
|
2331
|
+
uri ? uriToDid(uri) : undefined,
|
|
2332
|
+
) as DidString[],
|
|
2333
|
+
)
|
|
2334
|
+
const associatedProfiles = mapDefined(uniqueDids, (did) =>
|
|
2335
|
+
this.profileBasic(did, state),
|
|
2336
|
+
) as ProfileViewBasic[]
|
|
2337
|
+
if (associatedProfiles.length)
|
|
2338
|
+
overlay.associatedProfiles = associatedProfiles
|
|
2339
|
+
|
|
2317
2340
|
return overlay
|
|
2318
2341
|
}
|
|
2319
2342
|
|
|
@@ -724,18 +724,14 @@ describe('pds profile views', () => {
|
|
|
724
724
|
})
|
|
725
725
|
|
|
726
726
|
it('filters out Go zero-value dates from dataplane', async () => {
|
|
727
|
-
|
|
728
|
-
const getActorsSpy = vi.spyOn(network.bsky.ctx.dataplane, 'getActors')
|
|
727
|
+
using getActorsSpy = vi.spyOn(network.bsky.ctx.dataplane, 'getActors')
|
|
729
728
|
|
|
730
|
-
// Call the original implementation but modify the result
|
|
731
729
|
getActorsSpy.mockImplementationOnce(async (req) => {
|
|
732
|
-
// Call the real method
|
|
733
730
|
const result = await network.bsky.ctx.dataplane.getActors(req)
|
|
734
731
|
|
|
735
|
-
//
|
|
732
|
+
// Inject a Go zero-value date (0001-01-01 00:00:00 UTC)
|
|
736
733
|
if (result.actors.length > 0 && result.actors[0]) {
|
|
737
734
|
const actor = result.actors[0]
|
|
738
|
-
// Create a Timestamp with Go zero-value (0001-01-01 00:00:00 UTC)
|
|
739
735
|
const goZeroDate = new Date(-62135596800000)
|
|
740
736
|
actor.createdAt = Timestamp.fromDate(goZeroDate)
|
|
741
737
|
}
|
|
@@ -750,11 +746,8 @@ describe('pds profile views', () => {
|
|
|
750
746
|
},
|
|
751
747
|
)
|
|
752
748
|
|
|
753
|
-
// The
|
|
749
|
+
// The hydration layer filters Go zero-values out
|
|
754
750
|
expect(data.createdAt).toBeUndefined()
|
|
755
|
-
|
|
756
|
-
// Clean up
|
|
757
|
-
getActorsSpy.mockRestore()
|
|
758
751
|
})
|
|
759
752
|
|
|
760
753
|
async function updateProfile(did: string, record: Record<string, unknown>) {
|