@atproto/bsky 0.0.235 → 0.0.237

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 (135) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/hydration/hydrator.d.ts.map +1 -1
  3. package/dist/hydration/hydrator.js +27 -0
  4. package/dist/hydration/hydrator.js.map +1 -1
  5. package/dist/lexicons/app/bsky/actor/defs.defs.d.ts +8 -0
  6. package/dist/lexicons/app/bsky/actor/defs.defs.d.ts.map +1 -1
  7. package/dist/lexicons/app/bsky/actor/defs.defs.js +3 -0
  8. package/dist/lexicons/app/bsky/actor/defs.defs.js.map +1 -1
  9. package/dist/lexicons/chat/bsky/authFullChatClient.defs.d.ts.map +1 -1
  10. package/dist/lexicons/chat/bsky/authFullChatClient.defs.js +1 -0
  11. package/dist/lexicons/chat/bsky/authFullChatClient.defs.js.map +1 -1
  12. package/dist/lexicons/chat/bsky/convo/defs.defs.d.ts +53 -14
  13. package/dist/lexicons/chat/bsky/convo/defs.defs.d.ts.map +1 -1
  14. package/dist/lexicons/chat/bsky/convo/defs.defs.js +33 -5
  15. package/dist/lexicons/chat/bsky/convo/defs.defs.js.map +1 -1
  16. package/dist/lexicons/chat/bsky/convo/getConvoForMembers.defs.d.ts +1 -1
  17. package/dist/lexicons/chat/bsky/convo/getConvoForMembers.defs.d.ts.map +1 -1
  18. package/dist/lexicons/chat/bsky/convo/getConvoForMembers.defs.js +1 -0
  19. package/dist/lexicons/chat/bsky/convo/getConvoForMembers.defs.js.map +1 -1
  20. package/dist/lexicons/chat/bsky/convo/getLog.defs.d.ts +2 -2
  21. package/dist/lexicons/chat/bsky/convo/getLog.defs.d.ts.map +1 -1
  22. package/dist/lexicons/chat/bsky/convo/getLog.defs.js +3 -0
  23. package/dist/lexicons/chat/bsky/convo/getLog.defs.js.map +1 -1
  24. package/dist/lexicons/chat/bsky/embed/joinLink.d.ts +3 -0
  25. package/dist/lexicons/chat/bsky/embed/joinLink.d.ts.map +1 -0
  26. package/dist/lexicons/chat/bsky/embed/joinLink.defs.d.ts +99 -0
  27. package/dist/lexicons/chat/bsky/embed/joinLink.defs.d.ts.map +1 -0
  28. package/dist/lexicons/chat/bsky/embed/joinLink.defs.js +28 -0
  29. package/dist/lexicons/chat/bsky/embed/joinLink.defs.js.map +1 -0
  30. package/dist/lexicons/chat/bsky/embed/joinLink.js +6 -0
  31. package/dist/lexicons/chat/bsky/embed/joinLink.js.map +1 -0
  32. package/dist/lexicons/chat/bsky/embed.d.ts +2 -0
  33. package/dist/lexicons/chat/bsky/embed.d.ts.map +1 -0
  34. package/dist/lexicons/chat/bsky/embed.js +5 -0
  35. package/dist/lexicons/chat/bsky/embed.js.map +1 -0
  36. package/dist/lexicons/chat/bsky/group/addMembers.defs.d.ts +1 -1
  37. package/dist/lexicons/chat/bsky/group/addMembers.defs.d.ts.map +1 -1
  38. package/dist/lexicons/chat/bsky/group/addMembers.defs.js +1 -0
  39. package/dist/lexicons/chat/bsky/group/addMembers.defs.js.map +1 -1
  40. package/dist/lexicons/chat/bsky/group/createGroup.defs.d.ts +1 -1
  41. package/dist/lexicons/chat/bsky/group/createGroup.defs.d.ts.map +1 -1
  42. package/dist/lexicons/chat/bsky/group/createGroup.defs.js +1 -0
  43. package/dist/lexicons/chat/bsky/group/createGroup.defs.js.map +1 -1
  44. package/dist/lexicons/chat/bsky/group/getJoinLinkPreviews.defs.d.ts +1 -1
  45. package/dist/lexicons/chat/bsky/group/getJoinLinkPreviews.defs.d.ts.map +1 -1
  46. package/dist/lexicons/chat/bsky/group/getJoinLinkPreviews.defs.js +1 -1
  47. package/dist/lexicons/chat/bsky/group/getJoinLinkPreviews.defs.js.map +1 -1
  48. package/dist/lexicons/chat/bsky/group/updateJoinRequestsRead.d.ts +3 -0
  49. package/dist/lexicons/chat/bsky/group/updateJoinRequestsRead.d.ts.map +1 -0
  50. package/dist/lexicons/chat/bsky/group/updateJoinRequestsRead.defs.d.ts +20 -0
  51. package/dist/lexicons/chat/bsky/group/updateJoinRequestsRead.defs.d.ts.map +1 -0
  52. package/dist/lexicons/chat/bsky/group/updateJoinRequestsRead.defs.js +19 -0
  53. package/dist/lexicons/chat/bsky/group/updateJoinRequestsRead.defs.js.map +1 -0
  54. package/dist/lexicons/chat/bsky/group/updateJoinRequestsRead.js +6 -0
  55. package/dist/lexicons/chat/bsky/group/updateJoinRequestsRead.js.map +1 -0
  56. package/dist/lexicons/chat/bsky/group/withdrawJoinRequest.d.ts +3 -0
  57. package/dist/lexicons/chat/bsky/group/withdrawJoinRequest.d.ts.map +1 -0
  58. package/dist/lexicons/chat/bsky/group/withdrawJoinRequest.defs.d.ts +20 -0
  59. package/dist/lexicons/chat/bsky/group/withdrawJoinRequest.defs.d.ts.map +1 -0
  60. package/dist/lexicons/chat/bsky/group/withdrawJoinRequest.defs.js +18 -0
  61. package/dist/lexicons/chat/bsky/group/withdrawJoinRequest.defs.js.map +1 -0
  62. package/dist/lexicons/chat/bsky/group/withdrawJoinRequest.js +6 -0
  63. package/dist/lexicons/chat/bsky/group/withdrawJoinRequest.js.map +1 -0
  64. package/dist/lexicons/chat/bsky/group.d.ts +2 -0
  65. package/dist/lexicons/chat/bsky/group.d.ts.map +1 -1
  66. package/dist/lexicons/chat/bsky/group.js +2 -0
  67. package/dist/lexicons/chat/bsky/group.js.map +1 -1
  68. package/dist/lexicons/chat/bsky/moderation/defs.d.ts +2 -0
  69. package/dist/lexicons/chat/bsky/moderation/defs.d.ts.map +1 -0
  70. package/dist/lexicons/chat/bsky/moderation/defs.defs.d.ts +58 -0
  71. package/dist/lexicons/chat/bsky/moderation/defs.defs.d.ts.map +1 -0
  72. package/dist/lexicons/chat/bsky/moderation/defs.defs.js +38 -0
  73. package/dist/lexicons/chat/bsky/moderation/defs.defs.js.map +1 -0
  74. package/dist/lexicons/chat/bsky/moderation/defs.js +5 -0
  75. package/dist/lexicons/chat/bsky/moderation/defs.js.map +1 -0
  76. package/dist/lexicons/chat/bsky/moderation/getConvo.d.ts +3 -0
  77. package/dist/lexicons/chat/bsky/moderation/getConvo.d.ts.map +1 -0
  78. package/dist/lexicons/chat/bsky/moderation/getConvo.defs.d.ts +22 -0
  79. package/dist/lexicons/chat/bsky/moderation/getConvo.defs.d.ts.map +1 -0
  80. package/dist/lexicons/chat/bsky/moderation/getConvo.defs.js +18 -0
  81. package/dist/lexicons/chat/bsky/moderation/getConvo.defs.js.map +1 -0
  82. package/dist/lexicons/chat/bsky/moderation/getConvo.js +6 -0
  83. package/dist/lexicons/chat/bsky/moderation/getConvo.js.map +1 -0
  84. package/dist/lexicons/chat/bsky/moderation/getConvoMembers.d.ts +3 -0
  85. package/dist/lexicons/chat/bsky/moderation/getConvoMembers.d.ts.map +1 -0
  86. package/dist/lexicons/chat/bsky/moderation/getConvoMembers.defs.d.ts +28 -0
  87. package/dist/lexicons/chat/bsky/moderation/getConvoMembers.defs.d.ts.map +1 -0
  88. package/dist/lexicons/chat/bsky/moderation/getConvoMembers.defs.js +24 -0
  89. package/dist/lexicons/chat/bsky/moderation/getConvoMembers.defs.js.map +1 -0
  90. package/dist/lexicons/chat/bsky/moderation/getConvoMembers.js +6 -0
  91. package/dist/lexicons/chat/bsky/moderation/getConvoMembers.js.map +1 -0
  92. package/dist/lexicons/chat/bsky/moderation/getConvos.d.ts +3 -0
  93. package/dist/lexicons/chat/bsky/moderation/getConvos.d.ts.map +1 -0
  94. package/dist/lexicons/chat/bsky/moderation/getConvos.defs.d.ts +22 -0
  95. package/dist/lexicons/chat/bsky/moderation/getConvos.defs.d.ts.map +1 -0
  96. package/dist/lexicons/chat/bsky/moderation/getConvos.defs.js +22 -0
  97. package/dist/lexicons/chat/bsky/moderation/getConvos.defs.js.map +1 -0
  98. package/dist/lexicons/chat/bsky/moderation/getConvos.js +6 -0
  99. package/dist/lexicons/chat/bsky/moderation/getConvos.js.map +1 -0
  100. package/dist/lexicons/chat/bsky/moderation/subscribeModEvents.defs.d.ts +20 -2
  101. package/dist/lexicons/chat/bsky/moderation/subscribeModEvents.defs.d.ts.map +1 -1
  102. package/dist/lexicons/chat/bsky/moderation/subscribeModEvents.defs.js +11 -0
  103. package/dist/lexicons/chat/bsky/moderation/subscribeModEvents.defs.js.map +1 -1
  104. package/dist/lexicons/chat/bsky/moderation.d.ts +4 -0
  105. package/dist/lexicons/chat/bsky/moderation.d.ts.map +1 -1
  106. package/dist/lexicons/chat/bsky/moderation.js +4 -0
  107. package/dist/lexicons/chat/bsky/moderation.js.map +1 -1
  108. package/dist/lexicons/chat/bsky.d.ts +1 -0
  109. package/dist/lexicons/chat/bsky.d.ts.map +1 -1
  110. package/dist/lexicons/chat/bsky.js +1 -0
  111. package/dist/lexicons/chat/bsky.js.map +1 -1
  112. package/dist/lexicons/tools/ozone/moderation/defs.defs.d.ts +11 -3
  113. package/dist/lexicons/tools/ozone/moderation/defs.defs.d.ts.map +1 -1
  114. package/dist/lexicons/tools/ozone/moderation/defs.defs.js +9 -0
  115. package/dist/lexicons/tools/ozone/moderation/defs.defs.js.map +1 -1
  116. package/dist/lexicons/tools/ozone/moderation/queryEvents.defs.d.ts +2 -2
  117. package/dist/lexicons/tools/ozone/moderation/queryEvents.defs.d.ts.map +1 -1
  118. package/dist/lexicons/tools/ozone/moderation/queryEvents.defs.js.map +1 -1
  119. package/dist/lexicons/tools/ozone/moderation/queryStatuses.defs.d.ts +2 -2
  120. package/dist/lexicons/tools/ozone/moderation/queryStatuses.defs.d.ts.map +1 -1
  121. package/dist/lexicons/tools/ozone/moderation/queryStatuses.defs.js.map +1 -1
  122. package/dist/util/standard-site.d.ts +12 -4
  123. package/dist/util/standard-site.d.ts.map +1 -1
  124. package/dist/util/standard-site.js +58 -6
  125. package/dist/util/standard-site.js.map +1 -1
  126. package/dist/views/index.d.ts.map +1 -1
  127. package/dist/views/index.js +16 -3
  128. package/dist/views/index.js.map +1 -1
  129. package/package.json +6 -6
  130. package/src/hydration/hydrator.ts +24 -0
  131. package/src/util/standard-site.test.ts +145 -0
  132. package/src/util/standard-site.ts +67 -6
  133. package/src/views/index.ts +21 -7
  134. package/tests/views/verification.test.ts +16 -0
  135. 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.235",
3
+ "version": "0.0.237",
4
4
  "license": "MIT",
5
5
  "description": "Reference implementation of app.bsky App View (Bluesky API)",
6
6
  "keywords": [
@@ -50,16 +50,16 @@
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.6",
54
53
  "@atproto/common": "^0.6.1",
54
+ "@atproto/api": "^0.20.8",
55
55
  "@atproto/crypto": "^0.5.0",
56
56
  "@atproto/did": "^0.5.0",
57
- "@atproto/identity": "^0.5.0",
58
- "@atproto/lex": "^0.1.3",
59
57
  "@atproto/repo": "^0.10.0",
60
- "@atproto/sync": "^0.3.1",
61
58
  "@atproto/syntax": "^0.6.1",
62
- "@atproto/xrpc-server": "^0.11.1"
59
+ "@atproto/xrpc-server": "^0.11.1",
60
+ "@atproto/sync": "^0.3.1",
61
+ "@atproto/identity": "^0.5.0",
62
+ "@atproto/lex": "^0.1.3"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@bufbuild/buf": "^1.28.1",
@@ -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
  }
@@ -275,4 +275,149 @@ describe(validateStandardSiteForUrl, () => {
275
275
  })
276
276
  }
277
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
+ })
278
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
@@ -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,19 +2149,25 @@ export class Views {
2141
2149
  state,
2142
2150
  assumedUrl: embed.external.uri,
2143
2151
  })
2152
+ // The author-supplied (scraped) thumbnail always wins when present —
2153
+ // it's the per-article OG image. Only when the embed has no thumb do
2154
+ // we fall back to whatever the SS overlay provides (the document's
2155
+ // `coverImage`). `thumb` is set after the `...ssView` spread so the
2156
+ // overlay's `coverImage`-derived thumb can't clobber the embed's.
2157
+ const embeddedThumb = embed.external.thumb
2158
+ ? this.imgUriBuilder.getPresetUri(
2159
+ 'feed_thumbnail',
2160
+ did,
2161
+ getBlobCidString(embed.external.thumb),
2162
+ )
2163
+ : undefined
2144
2164
  return app.bsky.embed.external.view.$build({
2145
2165
  external: {
2146
2166
  uri: embed.external.uri,
2147
2167
  title: embed.external.title,
2148
2168
  description: embed.external.description,
2149
- thumb: embed.external.thumb
2150
- ? this.imgUriBuilder.getPresetUri(
2151
- 'feed_thumbnail',
2152
- did,
2153
- getBlobCidString(embed.external.thumb),
2154
- )
2155
- : undefined,
2156
2169
  ...ssView,
2170
+ thumb: embeddedThumb ?? ssView?.thumb,
2157
2171
  associatedRefs: embed.external.associatedRefs,
2158
2172
  },
2159
2173
  })
@@ -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),