@atproto/bsky 0.0.234 → 0.0.236

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 (139) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/hydration/external.d.ts.map +1 -1
  3. package/dist/hydration/external.js +8 -0
  4. package/dist/hydration/external.js.map +1 -1
  5. package/dist/hydration/hydrator.d.ts.map +1 -1
  6. package/dist/hydration/hydrator.js +27 -0
  7. package/dist/hydration/hydrator.js.map +1 -1
  8. package/dist/lexicons/app/bsky/actor/defs.defs.d.ts +8 -0
  9. package/dist/lexicons/app/bsky/actor/defs.defs.d.ts.map +1 -1
  10. package/dist/lexicons/app/bsky/actor/defs.defs.js +3 -0
  11. package/dist/lexicons/app/bsky/actor/defs.defs.js.map +1 -1
  12. package/dist/lexicons/chat/bsky/actor/getStatus.defs.d.ts +2 -0
  13. package/dist/lexicons/chat/bsky/actor/getStatus.defs.d.ts.map +1 -1
  14. package/dist/lexicons/chat/bsky/actor/getStatus.defs.js +1 -0
  15. package/dist/lexicons/chat/bsky/actor/getStatus.defs.js.map +1 -1
  16. package/dist/lexicons/chat/bsky/authFullChatClient.defs.d.ts.map +1 -1
  17. package/dist/lexicons/chat/bsky/authFullChatClient.defs.js +1 -0
  18. package/dist/lexicons/chat/bsky/authFullChatClient.defs.js.map +1 -1
  19. package/dist/lexicons/chat/bsky/convo/defs.defs.d.ts +53 -14
  20. package/dist/lexicons/chat/bsky/convo/defs.defs.d.ts.map +1 -1
  21. package/dist/lexicons/chat/bsky/convo/defs.defs.js +33 -5
  22. package/dist/lexicons/chat/bsky/convo/defs.defs.js.map +1 -1
  23. package/dist/lexicons/chat/bsky/convo/getConvoForMembers.defs.d.ts +1 -1
  24. package/dist/lexicons/chat/bsky/convo/getConvoForMembers.defs.d.ts.map +1 -1
  25. package/dist/lexicons/chat/bsky/convo/getConvoForMembers.defs.js +1 -0
  26. package/dist/lexicons/chat/bsky/convo/getConvoForMembers.defs.js.map +1 -1
  27. package/dist/lexicons/chat/bsky/convo/getLog.defs.d.ts +2 -2
  28. package/dist/lexicons/chat/bsky/convo/getLog.defs.d.ts.map +1 -1
  29. package/dist/lexicons/chat/bsky/convo/getLog.defs.js +3 -0
  30. package/dist/lexicons/chat/bsky/convo/getLog.defs.js.map +1 -1
  31. package/dist/lexicons/chat/bsky/embed/joinLink.d.ts +3 -0
  32. package/dist/lexicons/chat/bsky/embed/joinLink.d.ts.map +1 -0
  33. package/dist/lexicons/chat/bsky/embed/joinLink.defs.d.ts +99 -0
  34. package/dist/lexicons/chat/bsky/embed/joinLink.defs.d.ts.map +1 -0
  35. package/dist/lexicons/chat/bsky/embed/joinLink.defs.js +28 -0
  36. package/dist/lexicons/chat/bsky/embed/joinLink.defs.js.map +1 -0
  37. package/dist/lexicons/chat/bsky/embed/joinLink.js +6 -0
  38. package/dist/lexicons/chat/bsky/embed/joinLink.js.map +1 -0
  39. package/dist/lexicons/chat/bsky/embed.d.ts +2 -0
  40. package/dist/lexicons/chat/bsky/embed.d.ts.map +1 -0
  41. package/dist/lexicons/chat/bsky/embed.js +5 -0
  42. package/dist/lexicons/chat/bsky/embed.js.map +1 -0
  43. package/dist/lexicons/chat/bsky/group/addMembers.defs.d.ts +1 -1
  44. package/dist/lexicons/chat/bsky/group/addMembers.defs.d.ts.map +1 -1
  45. package/dist/lexicons/chat/bsky/group/addMembers.defs.js +1 -0
  46. package/dist/lexicons/chat/bsky/group/addMembers.defs.js.map +1 -1
  47. package/dist/lexicons/chat/bsky/group/createGroup.defs.d.ts +1 -1
  48. package/dist/lexicons/chat/bsky/group/createGroup.defs.d.ts.map +1 -1
  49. package/dist/lexicons/chat/bsky/group/createGroup.defs.js +1 -0
  50. package/dist/lexicons/chat/bsky/group/createGroup.defs.js.map +1 -1
  51. package/dist/lexicons/chat/bsky/group/getJoinLinkPreviews.defs.d.ts +1 -1
  52. package/dist/lexicons/chat/bsky/group/getJoinLinkPreviews.defs.d.ts.map +1 -1
  53. package/dist/lexicons/chat/bsky/group/getJoinLinkPreviews.defs.js +1 -1
  54. package/dist/lexicons/chat/bsky/group/getJoinLinkPreviews.defs.js.map +1 -1
  55. package/dist/lexicons/chat/bsky/group/updateJoinRequestsRead.d.ts +3 -0
  56. package/dist/lexicons/chat/bsky/group/updateJoinRequestsRead.d.ts.map +1 -0
  57. package/dist/lexicons/chat/bsky/group/updateJoinRequestsRead.defs.d.ts +20 -0
  58. package/dist/lexicons/chat/bsky/group/updateJoinRequestsRead.defs.d.ts.map +1 -0
  59. package/dist/lexicons/chat/bsky/group/updateJoinRequestsRead.defs.js +19 -0
  60. package/dist/lexicons/chat/bsky/group/updateJoinRequestsRead.defs.js.map +1 -0
  61. package/dist/lexicons/chat/bsky/group/updateJoinRequestsRead.js +6 -0
  62. package/dist/lexicons/chat/bsky/group/updateJoinRequestsRead.js.map +1 -0
  63. package/dist/lexicons/chat/bsky/group/withdrawJoinRequest.d.ts +3 -0
  64. package/dist/lexicons/chat/bsky/group/withdrawJoinRequest.d.ts.map +1 -0
  65. package/dist/lexicons/chat/bsky/group/withdrawJoinRequest.defs.d.ts +20 -0
  66. package/dist/lexicons/chat/bsky/group/withdrawJoinRequest.defs.d.ts.map +1 -0
  67. package/dist/lexicons/chat/bsky/group/withdrawJoinRequest.defs.js +18 -0
  68. package/dist/lexicons/chat/bsky/group/withdrawJoinRequest.defs.js.map +1 -0
  69. package/dist/lexicons/chat/bsky/group/withdrawJoinRequest.js +6 -0
  70. package/dist/lexicons/chat/bsky/group/withdrawJoinRequest.js.map +1 -0
  71. package/dist/lexicons/chat/bsky/group.d.ts +2 -0
  72. package/dist/lexicons/chat/bsky/group.d.ts.map +1 -1
  73. package/dist/lexicons/chat/bsky/group.js +2 -0
  74. package/dist/lexicons/chat/bsky/group.js.map +1 -1
  75. package/dist/lexicons/chat/bsky/moderation/defs.d.ts +2 -0
  76. package/dist/lexicons/chat/bsky/moderation/defs.d.ts.map +1 -0
  77. package/dist/lexicons/chat/bsky/moderation/defs.defs.d.ts +58 -0
  78. package/dist/lexicons/chat/bsky/moderation/defs.defs.d.ts.map +1 -0
  79. package/dist/lexicons/chat/bsky/moderation/defs.defs.js +38 -0
  80. package/dist/lexicons/chat/bsky/moderation/defs.defs.js.map +1 -0
  81. package/dist/lexicons/chat/bsky/moderation/defs.js +5 -0
  82. package/dist/lexicons/chat/bsky/moderation/defs.js.map +1 -0
  83. package/dist/lexicons/chat/bsky/moderation/getConvo.d.ts +3 -0
  84. package/dist/lexicons/chat/bsky/moderation/getConvo.d.ts.map +1 -0
  85. package/dist/lexicons/chat/bsky/moderation/getConvo.defs.d.ts +22 -0
  86. package/dist/lexicons/chat/bsky/moderation/getConvo.defs.d.ts.map +1 -0
  87. package/dist/lexicons/chat/bsky/moderation/getConvo.defs.js +18 -0
  88. package/dist/lexicons/chat/bsky/moderation/getConvo.defs.js.map +1 -0
  89. package/dist/lexicons/chat/bsky/moderation/getConvo.js +6 -0
  90. package/dist/lexicons/chat/bsky/moderation/getConvo.js.map +1 -0
  91. package/dist/lexicons/chat/bsky/moderation/getConvoMembers.d.ts +3 -0
  92. package/dist/lexicons/chat/bsky/moderation/getConvoMembers.d.ts.map +1 -0
  93. package/dist/lexicons/chat/bsky/moderation/getConvoMembers.defs.d.ts +28 -0
  94. package/dist/lexicons/chat/bsky/moderation/getConvoMembers.defs.d.ts.map +1 -0
  95. package/dist/lexicons/chat/bsky/moderation/getConvoMembers.defs.js +24 -0
  96. package/dist/lexicons/chat/bsky/moderation/getConvoMembers.defs.js.map +1 -0
  97. package/dist/lexicons/chat/bsky/moderation/getConvoMembers.js +6 -0
  98. package/dist/lexicons/chat/bsky/moderation/getConvoMembers.js.map +1 -0
  99. package/dist/lexicons/chat/bsky/moderation/subscribeModEvents.defs.d.ts +20 -2
  100. package/dist/lexicons/chat/bsky/moderation/subscribeModEvents.defs.d.ts.map +1 -1
  101. package/dist/lexicons/chat/bsky/moderation/subscribeModEvents.defs.js +11 -0
  102. package/dist/lexicons/chat/bsky/moderation/subscribeModEvents.defs.js.map +1 -1
  103. package/dist/lexicons/chat/bsky/moderation.d.ts +3 -0
  104. package/dist/lexicons/chat/bsky/moderation.d.ts.map +1 -1
  105. package/dist/lexicons/chat/bsky/moderation.js +3 -0
  106. package/dist/lexicons/chat/bsky/moderation.js.map +1 -1
  107. package/dist/lexicons/chat/bsky.d.ts +1 -0
  108. package/dist/lexicons/chat/bsky.d.ts.map +1 -1
  109. package/dist/lexicons/chat/bsky.js +1 -0
  110. package/dist/lexicons/chat/bsky.js.map +1 -1
  111. package/dist/lexicons/com/atproto/server/getServiceAuth.defs.d.ts +2 -2
  112. package/dist/lexicons/com/atproto/server/getServiceAuth.defs.js +1 -1
  113. package/dist/lexicons/com/atproto/server/getServiceAuth.defs.js.map +1 -1
  114. package/dist/lexicons/tools/ozone/moderation/defs.defs.d.ts +11 -3
  115. package/dist/lexicons/tools/ozone/moderation/defs.defs.d.ts.map +1 -1
  116. package/dist/lexicons/tools/ozone/moderation/defs.defs.js +9 -0
  117. package/dist/lexicons/tools/ozone/moderation/defs.defs.js.map +1 -1
  118. package/dist/lexicons/tools/ozone/moderation/queryEvents.defs.d.ts +2 -2
  119. package/dist/lexicons/tools/ozone/moderation/queryEvents.defs.d.ts.map +1 -1
  120. package/dist/lexicons/tools/ozone/moderation/queryEvents.defs.js.map +1 -1
  121. package/dist/lexicons/tools/ozone/moderation/queryStatuses.defs.d.ts +2 -2
  122. package/dist/lexicons/tools/ozone/moderation/queryStatuses.defs.d.ts.map +1 -1
  123. package/dist/lexicons/tools/ozone/moderation/queryStatuses.defs.js.map +1 -1
  124. package/dist/util/standard-site.d.ts +12 -4
  125. package/dist/util/standard-site.d.ts.map +1 -1
  126. package/dist/util/standard-site.js +58 -6
  127. package/dist/util/standard-site.js.map +1 -1
  128. package/dist/views/index.d.ts.map +1 -1
  129. package/dist/views/index.js +17 -10
  130. package/dist/views/index.js.map +1 -1
  131. package/package.json +6 -6
  132. package/src/hydration/external.ts +17 -0
  133. package/src/hydration/hydrator.ts +24 -0
  134. package/src/util/standard-site.test.ts +152 -0
  135. package/src/util/standard-site.ts +67 -6
  136. package/src/views/index.ts +25 -13
  137. package/tests/views/profile.test.ts +3 -10
  138. package/tests/views/verification.test.ts +16 -0
  139. package/tsconfig.build.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atproto/bsky",
3
- "version": "0.0.234",
3
+ "version": "0.0.236",
4
4
  "license": "MIT",
5
5
  "description": "Reference implementation of app.bsky App View (Bluesky API)",
6
6
  "keywords": [
@@ -48,14 +48,14 @@
48
48
  "uint8arrays": "^5.0.0",
49
49
  "undici": "^6.19.8",
50
50
  "zod": "3.23.8",
51
- "@atproto-labs/fetch-node": "^0.3.0",
52
51
  "@atproto-labs/xrpc-utils": "^0.1.0",
53
- "@atproto/api": "^0.20.5",
52
+ "@atproto-labs/fetch-node": "^0.3.0",
53
+ "@atproto/api": "^0.20.7",
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.5.0"
77
+ "@atproto/pds": "^0.5.1"
78
78
  },
79
79
  "type": "module",
80
80
  "exports": {
@@ -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
  }
@@ -277,6 +277,30 @@ export class Hydrator {
277
277
  this.label.getLabelsForSubjects(labelSubjectsForDid(dids), ctx.labelers),
278
278
  this.hydrateProfileViewers(dids, ctx),
279
279
  ])
280
+ // Hydrate verification issuer actors so the verification view can expose
281
+ // the issuer's current handle and displayName. Skipped when no hydrated
282
+ // actor has any verifications, which is the common case.
283
+ const issuerDids: DidString[] = []
284
+ const issuerDidSet = new Set<DidString>()
285
+ for (const actor of actors.values()) {
286
+ if (!actor) continue
287
+ for (const verification of actor.verifications) {
288
+ const issuer = verification.issuer
289
+ if (actors.has(issuer) || issuerDidSet.has(issuer)) continue
290
+ issuerDidSet.add(issuer)
291
+ issuerDids.push(issuer)
292
+ }
293
+ }
294
+ if (issuerDids.length > 0) {
295
+ const issuerActors = await this.actor.getActors(issuerDids, {
296
+ includeTakedowns,
297
+ })
298
+ // Merge into actors without overwriting existing entries (the original
299
+ // dids may have been fetched with skipCacheForDids, etc.).
300
+ for (const [did, actor] of issuerActors) {
301
+ if (!actors.has(did)) actors.set(did, actor)
302
+ }
303
+ }
280
304
  if (!includeTakedowns) {
281
305
  actionTakedownLabels(dids, actors, labels)
282
306
  }
@@ -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', () => {
@@ -268,4 +275,149 @@ describe(validateStandardSiteForUrl, () => {
268
275
  })
269
276
  }
270
277
  })
278
+
279
+ describe('subpath-friendly hosts', () => {
280
+ // Allowlist of hosts where each record's author owns the full subpath
281
+ // space under their canonical record URL. These platforms typically
282
+ // serve dynamic per-record subpaths (page numbers, revision ids,
283
+ // comment threads, etc.) under the same slug, so an `assumedUrl` with
284
+ // extra path segments past the record URL is still authentic content.
285
+ for (const { note, baseUrl, path, assumedUrl, expected } of [
286
+ // Allowlisted apex domain (subdomain) — extra path segments accepted.
287
+ {
288
+ note: 'pckt.blog subdomain accepts extra path segments past the record URL',
289
+ baseUrl: 'https://waow-tech.pckt.blog',
290
+ path: '/typeahead-more-like-typebehind-amirite-tzgmqge',
291
+ assumedUrl:
292
+ 'https://waow-tech.pckt.blog/typeahead-more-like-typebehind-amirite-tzgmqge/589/621',
293
+ expected: true,
294
+ },
295
+ {
296
+ note: 'leaflet.pub subdomain with one extra segment',
297
+ baseUrl: 'https://author.leaflet.pub',
298
+ path: '/post-slug',
299
+ assumedUrl: 'https://author.leaflet.pub/post-slug/v2',
300
+ expected: true,
301
+ },
302
+ {
303
+ note: 'offprint.app at apex with multi-segment extension',
304
+ baseUrl: 'https://offprint.app',
305
+ path: '/story/abc',
306
+ assumedUrl: 'https://offprint.app/story/abc/chapter/3',
307
+ expected: true,
308
+ },
309
+ // Allowlisted host but the assumed URL diverges from the path —
310
+ // still rejected; subpath is "extends with extra segments after a
311
+ // path-segment boundary," not "any URL on the same host."
312
+ {
313
+ note: 'pckt.blog rejects assumed URLs that diverge before the boundary',
314
+ baseUrl: 'https://blog.pckt.blog',
315
+ path: '/post-slug',
316
+ assumedUrl: 'https://blog.pckt.blog/different-post',
317
+ expected: false,
318
+ },
319
+ {
320
+ note: 'pckt.blog rejects partial-segment matches (no slash boundary)',
321
+ baseUrl: 'https://blog.pckt.blog',
322
+ path: '/foo',
323
+ assumedUrl: 'https://blog.pckt.blog/foobar',
324
+ expected: false,
325
+ },
326
+ // Non-allowlisted hosts — exact match still required.
327
+ {
328
+ note: 'arbitrary host rejects extra path segments',
329
+ baseUrl: 'https://example.com',
330
+ path: '/article',
331
+ assumedUrl: 'https://example.com/article/extra',
332
+ expected: false,
333
+ },
334
+ {
335
+ note: 'lookalike host (pckt.blog.evil.com) is NOT allowlisted',
336
+ baseUrl: 'https://pckt.blog.evil.com',
337
+ path: '/post',
338
+ assumedUrl: 'https://pckt.blog.evil.com/post/extra',
339
+ expected: false,
340
+ },
341
+ {
342
+ note: 'evilpckt.blog is NOT allowlisted (no subdomain dot before pckt.blog)',
343
+ baseUrl: 'https://evilpckt.blog',
344
+ path: '/post',
345
+ assumedUrl: 'https://evilpckt.blog/post/extra',
346
+ expected: false,
347
+ },
348
+ // Allowlist host with exact match still works (no regression).
349
+ {
350
+ note: 'pckt.blog still accepts exact match without subpath',
351
+ baseUrl: 'https://author.pckt.blog',
352
+ path: '/post-slug',
353
+ assumedUrl: 'https://author.pckt.blog/post-slug',
354
+ expected: true,
355
+ },
356
+ // Allowlist host with cross-host mismatch — still rejected.
357
+ {
358
+ note: 'allowlisted record host vs different host on assumed URL is rejected',
359
+ baseUrl: 'https://author.pckt.blog',
360
+ path: '/post',
361
+ assumedUrl: 'https://example.com/author.pckt.blog/post/extra',
362
+ expected: false,
363
+ },
364
+ ]) {
365
+ it(`doc + pub: ${note}`, () => {
366
+ const doc = makeDoc(
367
+ path === undefined ? { site: pubUri } : { site: pubUri, path },
368
+ )
369
+ const pub = makePub({ url: baseUrl })
370
+ expect(validateStandardSiteForUrl(doc, pub, assumedUrl)).toBe(expected)
371
+ })
372
+ it(`loose doc: ${note}`, () => {
373
+ const doc = makeDoc(
374
+ path === undefined ? { site: baseUrl } : { site: baseUrl, path },
375
+ )
376
+ expect(validateStandardSiteForUrl(doc, undefined, assumedUrl)).toBe(
377
+ expected,
378
+ )
379
+ })
380
+ }
381
+
382
+ // Publication-only validation never accepts subpaths — `assumedUrl`
383
+ // for a publication is the home-page URL, not an article underneath
384
+ // it. Subpath relaxation belongs to documents.
385
+ it('publication-only: allowlisted host still requires exact match', () => {
386
+ const pub = makePub({ url: 'https://author.pckt.blog' })
387
+ expect(
388
+ validateStandardSiteForUrl(
389
+ undefined,
390
+ pub,
391
+ 'https://author.pckt.blog/some/sub/path',
392
+ ),
393
+ ).toBe(false)
394
+ })
395
+
396
+ it('publication-only: non-allowlisted host rejects subpath', () => {
397
+ const pub = makePub({ url: 'https://example.com' })
398
+ expect(
399
+ validateStandardSiteForUrl(
400
+ undefined,
401
+ pub,
402
+ 'https://example.com/some/sub/path',
403
+ ),
404
+ ).toBe(false)
405
+ })
406
+
407
+ // Case-insensitivity on the host.
408
+ it('host comparison is case-insensitive', () => {
409
+ const doc = makeDoc({
410
+ site: pubUri,
411
+ path: '/post',
412
+ })
413
+ const pub = makePub({ url: 'https://Author.PCKT.blog' })
414
+ expect(
415
+ validateStandardSiteForUrl(
416
+ doc,
417
+ pub,
418
+ 'https://author.pckt.BLOG/post/extra',
419
+ ),
420
+ ).toBe(true)
421
+ })
422
+ })
271
423
  })
@@ -53,6 +53,52 @@ const joinPath = (base: string, path: string): string => {
53
53
  return `${baseTrimmed}/${pathTrimmed}`
54
54
  }
55
55
 
56
+ /**
57
+ * Apex domains whose authors own the full subpath space under their
58
+ * record-claimed URL. Each entry matches itself and any subdomain; e.g.
59
+ * `pckt.blog` matches `pckt.blog` and `waow-tech.pckt.blog`.
60
+ *
61
+ * On these domains, an `assumedUrl` whose pathname extends the canonical
62
+ * record URL with extra segments is treated as valid. Adding to this list
63
+ * is a trust call — only platforms where each record's URL space is
64
+ * authoritatively owned by its author belong here.
65
+ */
66
+ const SUBPATH_FRIENDLY_DOMAINS = ['pckt.blog', 'leaflet.pub', 'offprint.app']
67
+
68
+ const isSubpathFriendlyHost = (host: string): boolean => {
69
+ const lower = host.toLowerCase()
70
+ return SUBPATH_FRIENDLY_DOMAINS.some(
71
+ (domain) => lower === domain || lower.endsWith(`.${domain}`),
72
+ )
73
+ }
74
+
75
+ /**
76
+ * Return whether `recordUrl` and `assumedUrl` should validate as the same
77
+ * canonical content. Strictly equal canonical forms always match. On
78
+ * subpath-friendly hosts (see `SUBPATH_FRIENDLY_DOMAINS`), an `assumedUrl`
79
+ * whose path extends `recordUrl`'s with extra segments is also accepted.
80
+ *
81
+ * Both inputs are pre-canonicalized strings (`canonicalizeHttpUrl` output)
82
+ * with no trailing slash and no query/fragment.
83
+ */
84
+ const canonicalUrlMatchesAssumed = (
85
+ canonicalRecordUrl: string,
86
+ canonicalAssumedUrl: string,
87
+ ): boolean => {
88
+ if (canonicalRecordUrl === canonicalAssumedUrl) return true
89
+ // Subpath fallback. Both strings are canonicalized, so a real
90
+ // path-segment boundary at `recordUrl + '/'` (e.g. `/foo` vs `/foo-bar`
91
+ // never matches; `/foo` vs `/foo/bar` does).
92
+ if (!canonicalAssumedUrl.startsWith(`${canonicalRecordUrl}/`)) return false
93
+ let host: string
94
+ try {
95
+ host = new URL(canonicalAssumedUrl).host
96
+ } catch {
97
+ return false
98
+ }
99
+ return isSubpathFriendlyHost(host)
100
+ }
101
+
56
102
  /**
57
103
  * Confirm that the supplied SS records actually back `assumedUrl`. The
58
104
  * record-side URL is built by concatenating the publication URL (or the
@@ -74,13 +120,21 @@ const joinPath = (base: string, path: string): string => {
74
120
  * function runs the pair is already known to be structurally consistent,
75
121
  * so we only check whether the records back the URL.
76
122
  *
123
+ * For document validation, `SUBPATH_FRIENDLY_DOMAINS` (and their
124
+ * subdomains) accept an assumed URL whose path extends the canonical
125
+ * record URL with additional segments — these are platforms where each
126
+ * record's author owns the full subpath space under their claimed URL.
127
+ * Publication-only validation always requires exact match: there's no
128
+ * coherent "subpath" of a publication's home page.
129
+ *
77
130
  * Cases:
78
131
  * - Document + publication: `publication.url + document.path` must
79
- * canonicalize to `assumedUrl`.
132
+ * canonicalize to `assumedUrl` (or be a subpath-friendly prefix of it).
80
133
  * - Loose document (web-URL `site`): `document.site + document.path`
81
- * must canonicalize to `assumedUrl`. (Doc with at-uri `site` but no
82
- * publication can't reach this function — the lookups reject it.)
83
- * - Publication only: `publication.url` must canonicalize to
134
+ * must canonicalize to `assumedUrl` (or be a subpath-friendly prefix).
135
+ * (Doc with at-uri `site` but no publication can't reach this function
136
+ * the lookups reject it.)
137
+ * - Publication only: `publication.url` must canonicalize exactly to
84
138
  * `assumedUrl`.
85
139
  * - Neither: vacuously valid; the caller short-circuits before we get
86
140
  * here.
@@ -104,15 +158,22 @@ export const validateStandardSiteForUrl = (
104
158
  const joined = canonicalizeHttpUrl(
105
159
  joinPath(publication.info.record.url, document.info.record.path ?? ''),
106
160
  )
107
- return joined === canonicalAssumed
161
+ return (
162
+ joined !== null && canonicalUrlMatchesAssumed(joined, canonicalAssumed)
163
+ )
108
164
  }
109
165
  if (document) {
110
166
  const joined = canonicalizeHttpUrl(
111
167
  joinPath(document.info.record.site, document.info.record.path ?? ''),
112
168
  )
113
- return joined === canonicalAssumed
169
+ return (
170
+ joined !== null && canonicalUrlMatchesAssumed(joined, canonicalAssumed)
171
+ )
114
172
  }
115
173
  if (publication) {
174
+ // Publication-only matches are exact: `assumedUrl` represents the
175
+ // publication's home page, not an article underneath it. Subpath
176
+ // extensions belong to document validation.
116
177
  return canonicalizeHttpUrl(publication.info.record.url) === canonicalAssumed
117
178
  }
118
179
  return true
@@ -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,
@@ -535,14 +535,22 @@ export class Views {
535
535
  ({ issuer, uri, displayName, handle, createdAt }): VerificationView => {
536
536
  // @NOTE: We don't factor-in impersonation when evaluating the validity of each verification,
537
537
  // only in the overall profile verification validity.
538
+ // The verification record's `displayName`/`handle` are the *subject's* snapshot at
539
+ // verification time; we compare them to the subject's current values to determine validity.
538
540
  const isValid =
539
541
  !!displayName &&
540
542
  displayName === actor.profile?.displayName &&
541
543
  !!handle &&
542
544
  handle === actor.handle
543
545
 
546
+ // Expose the *issuer's* current handle/displayName, sourced from the
547
+ // issuer's hydrated actor record (see `Hydrator.hydrateProfiles`).
548
+ const issuerActor = state.actors?.get(issuer)
549
+
544
550
  return {
545
551
  issuer,
552
+ issuerDisplayName: issuerActor?.profile?.displayName,
553
+ issuerHandle: issuerActor?.handle,
546
554
  uri,
547
555
  isValid,
548
556
  createdAt,
@@ -2141,14 +2149,6 @@ export class Views {
2141
2149
  state,
2142
2150
  assumedUrl: embed.external.uri,
2143
2151
  })
2144
- // Profiles of the owners of pinned `associatedRefs`. Hydrator covers
2145
- // these DIDs alongside post-author profiles, so misses here only
2146
- // happen when an actor is unavailable (suspended, deleted, etc.) —
2147
- // drop those rather than emit `undefined` slots.
2148
- const associatedProfiles = mapDefined(
2149
- embed.external.associatedRefs ?? [],
2150
- (ref) => this.profileBasic(uriToDid(ref.uri), state),
2151
- ) as ProfileViewBasic[]
2152
2152
  return app.bsky.embed.external.view.$build({
2153
2153
  external: {
2154
2154
  uri: embed.external.uri,
@@ -2163,9 +2163,6 @@ export class Views {
2163
2163
  : undefined,
2164
2164
  ...ssView,
2165
2165
  associatedRefs: embed.external.associatedRefs,
2166
- associatedProfiles: associatedProfiles.length
2167
- ? associatedProfiles
2168
- : undefined,
2169
2166
  },
2170
2167
  })
2171
2168
  }
@@ -2263,7 +2260,7 @@ export class Views {
2263
2260
  documentUri: document?.ref.uri,
2264
2261
  publicationUri: publication?.ref.uri,
2265
2262
  },
2266
- 'SS record(s) failed URL validation for external embed',
2263
+ 'site.standard URL validation failed',
2267
2264
  )
2268
2265
  return undefined
2269
2266
  }
@@ -2333,6 +2330,21 @@ export class Views {
2333
2330
  overlay.source = this.externalEmbedSource(publication)
2334
2331
  }
2335
2332
 
2333
+ // Profiles of the owners of the records backing this embed. Hydrator
2334
+ // covers these DIDs alongside post-author profiles, so misses here
2335
+ // only happen when an actor is unavailable (suspended, deleted, etc.)
2336
+ // — drop those rather than emit `undefined` slots.
2337
+ const uniqueDids = dedupeStrs(
2338
+ mapDefined([document?.ref.uri, publication?.ref.uri], (uri) =>
2339
+ uri ? uriToDid(uri) : undefined,
2340
+ ) as DidString[],
2341
+ )
2342
+ const associatedProfiles = mapDefined(uniqueDids, (did) =>
2343
+ this.profileBasic(did, state),
2344
+ ) as ProfileViewBasic[]
2345
+ if (associatedProfiles.length)
2346
+ overlay.associatedProfiles = associatedProfiles
2347
+
2336
2348
  return overlay
2337
2349
  }
2338
2350
 
@@ -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>) {
@@ -78,6 +78,8 @@ describe('verification views', () => {
78
78
  verifications: [
79
79
  {
80
80
  createdAt: expect.any(String),
81
+ issuerDisplayName: 'display-verifier2',
82
+ issuerHandle: 'verifier2.test',
81
83
  isValid: true,
82
84
  issuer: verifier2,
83
85
  uri: expect.any(String),
@@ -115,12 +117,16 @@ describe('verification views', () => {
115
117
  verifications: [
116
118
  {
117
119
  createdAt: expect.any(String),
120
+ issuerDisplayName: 'display-verifier1',
121
+ issuerHandle: 'verifier1.test',
118
122
  isValid: true,
119
123
  issuer: verifier1,
120
124
  uri: expect.any(String),
121
125
  },
122
126
  {
123
127
  createdAt: expect.any(String),
128
+ issuerDisplayName: 'display-verifier2',
129
+ issuerHandle: 'verifier2.test',
124
130
  isValid: true,
125
131
  issuer: verifier2,
126
132
  uri: expect.any(String),
@@ -141,12 +147,16 @@ describe('verification views', () => {
141
147
  verifications: [
142
148
  {
143
149
  createdAt: expect.any(String),
150
+ issuerDisplayName: 'display-verifier1',
151
+ issuerHandle: 'verifier1.test',
144
152
  isValid: true,
145
153
  issuer: verifier1,
146
154
  uri: expect.any(String),
147
155
  },
148
156
  {
149
157
  createdAt: expect.any(String),
158
+ issuerDisplayName: 'display-verifier2',
159
+ issuerHandle: 'verifier2.test',
150
160
  isValid: false,
151
161
  issuer: verifier2,
152
162
  uri: expect.any(String),
@@ -167,6 +177,8 @@ describe('verification views', () => {
167
177
  verifications: [
168
178
  {
169
179
  createdAt: expect.any(String),
180
+ issuerDisplayName: 'display-verifier1',
181
+ issuerHandle: 'verifier1.test',
170
182
  isValid: true,
171
183
  issuer: verifier1,
172
184
  uri: expect.any(String),
@@ -193,6 +205,8 @@ describe('verification views', () => {
193
205
  verifications: [
194
206
  {
195
207
  createdAt: expect.any(String),
208
+ issuerDisplayName: 'display-verifier2',
209
+ issuerHandle: 'verifier2.test',
196
210
  isValid: false,
197
211
  issuer: verifier2,
198
212
  uri: expect.any(String),
@@ -219,6 +233,8 @@ describe('verification views', () => {
219
233
  verifications: [
220
234
  {
221
235
  createdAt: expect.any(String),
236
+ issuerDisplayName: 'display-verifier1',
237
+ issuerHandle: 'verifier1.test',
222
238
  isValid: true,
223
239
  issuer: verifier1,
224
240
  uri: expect.any(String),