@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.
Files changed (75) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/api/app/bsky/embed/getEmbedExternalView.js +5 -3
  3. package/dist/api/app/bsky/embed/getEmbedExternalView.js.map +1 -1
  4. package/dist/hydration/external.d.ts.map +1 -1
  5. package/dist/hydration/external.js +8 -0
  6. package/dist/hydration/external.js.map +1 -1
  7. package/dist/hydration/hydrator.d.ts.map +1 -1
  8. package/dist/hydration/hydrator.js +48 -5
  9. package/dist/hydration/hydrator.js.map +1 -1
  10. package/dist/lexicons/app/bsky/embed/external.defs.d.ts +5 -0
  11. package/dist/lexicons/app/bsky/embed/external.defs.d.ts.map +1 -1
  12. package/dist/lexicons/app/bsky/embed/external.defs.js +4 -0
  13. package/dist/lexicons/app/bsky/embed/external.defs.js.map +1 -1
  14. package/dist/lexicons/chat/bsky/actor/getStatus.defs.d.ts +2 -0
  15. package/dist/lexicons/chat/bsky/actor/getStatus.defs.d.ts.map +1 -1
  16. package/dist/lexicons/chat/bsky/actor/getStatus.defs.js +1 -0
  17. package/dist/lexicons/chat/bsky/actor/getStatus.defs.js.map +1 -1
  18. package/dist/lexicons/chat/bsky/authFullChatClient.defs.js +1 -1
  19. package/dist/lexicons/chat/bsky/authFullChatClient.defs.js.map +1 -1
  20. package/dist/lexicons/chat/bsky/convo/defs.defs.d.ts +8 -0
  21. package/dist/lexicons/chat/bsky/convo/defs.defs.d.ts.map +1 -1
  22. package/dist/lexicons/chat/bsky/convo/defs.defs.js +2 -0
  23. package/dist/lexicons/chat/bsky/convo/defs.defs.js.map +1 -1
  24. package/dist/lexicons/chat/bsky/convo/listConvoRequests.defs.d.ts +3 -3
  25. package/dist/lexicons/chat/bsky/convo/listConvoRequests.defs.d.ts.map +1 -1
  26. package/dist/lexicons/chat/bsky/convo/listConvoRequests.defs.js +2 -2
  27. package/dist/lexicons/chat/bsky/convo/listConvoRequests.defs.js.map +1 -1
  28. package/dist/lexicons/chat/bsky/group/defs.defs.d.ts +26 -0
  29. package/dist/lexicons/chat/bsky/group/defs.defs.d.ts.map +1 -1
  30. package/dist/lexicons/chat/bsky/group/defs.defs.js +23 -0
  31. package/dist/lexicons/chat/bsky/group/defs.defs.js.map +1 -1
  32. package/dist/lexicons/chat/bsky/group/getJoinLinkPreviews.d.ts +3 -0
  33. package/dist/lexicons/chat/bsky/group/getJoinLinkPreviews.d.ts.map +1 -0
  34. package/dist/lexicons/chat/bsky/group/getJoinLinkPreviews.defs.d.ts +22 -0
  35. package/dist/lexicons/chat/bsky/group/getJoinLinkPreviews.defs.d.ts.map +1 -0
  36. package/dist/lexicons/chat/bsky/group/getJoinLinkPreviews.defs.js +22 -0
  37. package/dist/lexicons/chat/bsky/group/getJoinLinkPreviews.defs.js.map +1 -0
  38. package/dist/lexicons/chat/bsky/group/getJoinLinkPreviews.js +6 -0
  39. package/dist/lexicons/chat/bsky/group/getJoinLinkPreviews.js.map +1 -0
  40. package/dist/lexicons/chat/bsky/group.d.ts +1 -1
  41. package/dist/lexicons/chat/bsky/group.d.ts.map +1 -1
  42. package/dist/lexicons/chat/bsky/group.js +1 -1
  43. package/dist/lexicons/chat/bsky/group.js.map +1 -1
  44. package/dist/lexicons/com/atproto/server/getServiceAuth.defs.d.ts +2 -2
  45. package/dist/lexicons/com/atproto/server/getServiceAuth.defs.js +1 -1
  46. package/dist/lexicons/com/atproto/server/getServiceAuth.defs.js.map +1 -1
  47. package/dist/logger.d.ts +1 -0
  48. package/dist/logger.d.ts.map +1 -1
  49. package/dist/logger.js +1 -0
  50. package/dist/logger.js.map +1 -1
  51. package/dist/util/standard-site.d.ts +12 -5
  52. package/dist/util/standard-site.d.ts.map +1 -1
  53. package/dist/util/standard-site.js +30 -9
  54. package/dist/util/standard-site.js.map +1 -1
  55. package/dist/views/index.d.ts.map +1 -1
  56. package/dist/views/index.js +14 -1
  57. package/dist/views/index.js.map +1 -1
  58. package/package.json +5 -5
  59. package/src/api/app/bsky/embed/getEmbedExternalView.ts +5 -3
  60. package/src/hydration/external.ts +17 -0
  61. package/src/hydration/hydrator.ts +53 -5
  62. package/src/logger.ts +2 -0
  63. package/src/util/standard-site.test.ts +116 -24
  64. package/src/util/standard-site.ts +30 -11
  65. package/src/views/index.ts +24 -1
  66. package/tests/views/profile.test.ts +3 -10
  67. package/tsconfig.build.tsbuildinfo +1 -1
  68. package/dist/lexicons/chat/bsky/group/getJoinLinkPreview.d.ts +0 -3
  69. package/dist/lexicons/chat/bsky/group/getJoinLinkPreview.d.ts.map +0 -1
  70. package/dist/lexicons/chat/bsky/group/getJoinLinkPreview.defs.d.ts +0 -22
  71. package/dist/lexicons/chat/bsky/group/getJoinLinkPreview.defs.d.ts.map +0 -1
  72. package/dist/lexicons/chat/bsky/group/getJoinLinkPreview.defs.js +0 -18
  73. package/dist/lexicons/chat/bsky/group/getJoinLinkPreview.defs.js.map +0 -1
  74. package/dist/lexicons/chat/bsky/group/getJoinLinkPreview.js +0 -6
  75. 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.233",
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.3",
53
+ "@atproto/api": "^0.20.6",
54
54
  "@atproto/common": "^0.6.1",
55
55
  "@atproto/crypto": "^0.5.0",
56
- "@atproto/did": "^0.4.0",
56
+ "@atproto/did": "^0.5.0",
57
57
  "@atproto/identity": "^0.5.0",
58
- "@atproto/lex": "^0.1.2",
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.4.225"
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 documents = inputs.hydration.siteStandardDocuments
65
- const publications = inputs.hydration.siteStandardPublications
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(allPostUris.map(didFromUri), ctx),
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
- profileState,
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
- return {
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('publication.url variants', () => {
162
- it('accepts when publication.url has trailing slash', () => {
163
- const doc = makeDoc({ site: pubUri, path: '/posts/hello' })
164
- const pub = makePub({ url: 'https://example.com/' })
165
- expect(
166
- validateStandardSiteForUrl(doc, pub, 'https://example.com/posts/hello'),
167
- ).toBe(true)
168
- })
169
-
170
- it('accepts when document.path lacks a leading slash', () => {
171
- const doc = makeDoc({ site: pubUri, path: 'posts/hello' })
172
- const pub = makePub({ url: 'https://example.com/' })
173
- expect(
174
- validateStandardSiteForUrl(doc, pub, 'https://example.com/posts/hello'),
175
- ).toBe(true)
176
- })
177
-
178
- it('handles publication.url with sub-path and document.path joining onto it', () => {
179
- const doc = makeDoc({ site: pubUri, path: 'hello' })
180
- const pub = makePub({ url: 'https://example.com/blog/' })
181
- expect(
182
- validateStandardSiteForUrl(doc, pub, 'https://example.com/blog/hello'),
183
- ).toBe(true)
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, base?: string): string | null => {
30
+ const canonicalizeHttpUrl = (url: string): string | null => {
31
31
  let parsed: URL
32
32
  try {
33
- parsed = new URL(url, base)
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
- * Confirm that the supplied SS records actually back `assumedUrl`. URL
44
- * comparison parses both sides via the WHATWG URL constructor (resolving
45
- * the document's `path` against the publication or site as a base) and
46
- * compares the canonical `protocol://host/path` forms — host case is
47
- * ignored, query/fragment are dropped, trailing slashes stripped.
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
  }
@@ -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
- // Spy on the dataplane getActors method
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
- // Modify the result to inject a Go zero-value date
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 createdAt should be undefined because the hydration layer filters it out
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>) {