@oxy.so/federation 1.0.0 → 1.0.1
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/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/networkIdentity.js +33 -5
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/networkIdentity.js +33 -5
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/networkIdentity.d.ts +4 -0
- package/package.json +1 -1
- package/src/__tests__/networkIdentity.test.ts +26 -0
- package/src/networkIdentity.ts +28 -5
|
@@ -337,6 +337,10 @@ export declare function upstreamHandleFromProfileField(options: {
|
|
|
337
337
|
readonly hosts: readonly string[];
|
|
338
338
|
/** Fixed path segments before the handle (`bsky.app/profile/<handle>` ⇒ `['profile']`). */
|
|
339
339
|
readonly pathPrefix?: readonly string[];
|
|
340
|
+
/** Opt-in for the exact BirdsiteLive double-HTTPS serialization defect. */
|
|
341
|
+
readonly repairRepeatedHttpsScheme?: boolean;
|
|
342
|
+
/** Require the named assertion's link to explicitly carry rel=me. */
|
|
343
|
+
readonly requireRelMe?: boolean;
|
|
340
344
|
}): BridgeDerivation;
|
|
341
345
|
/**
|
|
342
346
|
* Read the upstream handle out of `alsoKnownAs` profile URLs — the shape where an
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxy.so/federation",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "Oxy Federation — the app-agnostic ActivityPub identity + follow engine substrate: the network-connector contract, normalized cross-network DTOs, HTTP signatures, actor resolution, the outbound delivery transport + follow lifecycle, the inbound dispatcher, and the webfinger/actor/inbox Express routers. Domain-parameterized so every Oxy app backend federates under its own domain.",
|
|
5
5
|
"main": "dist/cjs/index.js",
|
|
6
6
|
"module": "dist/esm/index.js",
|
|
@@ -61,6 +61,32 @@ function candidate(overrides: Partial<NetworkIdentityCandidate> = {}): NetworkId
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
describe('createBridgeRelabeller', () => {
|
|
64
|
+
it('refuses contradictory accepted profile assertions regardless of their order', () => {
|
|
65
|
+
const derive = upstreamHandleFromProfileField({ fieldName: 'Official', hosts: ['x.com'], requireRelMe: true });
|
|
66
|
+
const fields = ['alice', 'bob'].map(handle => ({ name: 'Official', value: `<a href="https://x.com/${handle}" rel="me">Official</a>` }));
|
|
67
|
+
expect(derive(candidate({ fields }))).toBeUndefined();
|
|
68
|
+
expect(derive(candidate({ fields: [...fields].reverse() }))).toBeUndefined();
|
|
69
|
+
expect(derive(candidate({ fields: [fields[0], fields[0]] }))).toBe('alice');
|
|
70
|
+
expect(derive(candidate({ fields: [{ name: 'Official', value: '<a href="https://x.com/alice">Official</a>' }] }))).toBeUndefined();
|
|
71
|
+
});
|
|
72
|
+
it('repairs the observed double-HTTPS Official assertion only with the reviewed opt-in', () => {
|
|
73
|
+
const actor = candidate({ fields: [{ name: 'Official', value: '<a href="https://https://twitter.com/jordievole" rel="me">Official</a>' }] });
|
|
74
|
+
expect(upstreamHandleFromProfileField({ fieldName: 'Official', hosts: ['twitter.com'] })(actor)).toBeUndefined();
|
|
75
|
+
expect(upstreamHandleFromProfileField({ fieldName: 'Official', hosts: ['twitter.com'], repairRepeatedHttpsScheme: true })(actor)).toBe('jordievole');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it.each([
|
|
79
|
+
'https://https://attacker.example/jordievole',
|
|
80
|
+
'https://https://twitter.com@attacker.example/jordievole',
|
|
81
|
+
'https://https://attacker@twitter.com/jordievole',
|
|
82
|
+
'https://https://twitter.com:444/jordievole',
|
|
83
|
+
'https://https://https://twitter.com/jordievole',
|
|
84
|
+
'http://https://twitter.com/jordievole',
|
|
85
|
+
'https://https://twitter.com/i/status/123',
|
|
86
|
+
])('does not widen the repair into an attribution for %s', href => {
|
|
87
|
+
const actor = candidate({ fields: [{ name: 'Official', value: `<a href="${href}" rel="me">Official</a>` }] });
|
|
88
|
+
expect(upstreamHandleFromProfileField({ fieldName: 'Official', hosts: ['twitter.com'], repairRepeatedHttpsScheme: true })(actor)).toBeUndefined();
|
|
89
|
+
});
|
|
64
90
|
it('re-labels an actor onto the network its bridge mirrors', () => {
|
|
65
91
|
const identity = createBridgeRelabeller([entry()]).deriveNetworkIdentity(candidate());
|
|
66
92
|
expect(identity?.federatedUsername).toBe('wired@x.com');
|
package/src/networkIdentity.ts
CHANGED
|
@@ -164,6 +164,7 @@ export function parseUpstreamProfileUrl(
|
|
|
164
164
|
return undefined;
|
|
165
165
|
}
|
|
166
166
|
if (url.protocol !== 'https:' && url.protocol !== 'http:') return undefined;
|
|
167
|
+
if (url.username || url.password || url.port) return undefined;
|
|
167
168
|
|
|
168
169
|
const host = canonicalFederationHost(url.hostname);
|
|
169
170
|
for (const network of networks) {
|
|
@@ -397,6 +398,7 @@ function profileUrlHandle(
|
|
|
397
398
|
return undefined;
|
|
398
399
|
}
|
|
399
400
|
if (url.protocol !== 'https:' && url.protocol !== 'http:') return undefined;
|
|
401
|
+
if (url.username || url.password || url.port) return undefined;
|
|
400
402
|
const host = canonicalFederationHost(url.hostname);
|
|
401
403
|
if (!allowedHosts.some((allowed) => canonicalFederationHost(allowed) === host)) return undefined;
|
|
402
404
|
|
|
@@ -409,8 +411,17 @@ function profileUrlHandle(
|
|
|
409
411
|
}
|
|
410
412
|
|
|
411
413
|
/** Every `href="…"` in a sanitized field value, in document order. */
|
|
412
|
-
function fieldHrefs(value: string): string[] {
|
|
414
|
+
function fieldHrefs(value: string, requireRelMe = false): string[] {
|
|
413
415
|
const hrefs: string[] = [];
|
|
416
|
+
if (requireRelMe) {
|
|
417
|
+
for (const anchor of value.matchAll(/<a\b([^>]*)>/gi)) {
|
|
418
|
+
const rel = /\brel\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(anchor[1]);
|
|
419
|
+
if (!(rel?.[1] ?? rel?.[2] ?? '').toLowerCase().split(/\s+/).includes('me')) continue;
|
|
420
|
+
const href = /\bhref\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(anchor[1]);
|
|
421
|
+
if (href) hrefs.push(href[1] ?? href[2]);
|
|
422
|
+
}
|
|
423
|
+
return hrefs;
|
|
424
|
+
}
|
|
414
425
|
const pattern = /href="([^"]*)"/gi;
|
|
415
426
|
let match = pattern.exec(value);
|
|
416
427
|
while (match !== null) {
|
|
@@ -431,18 +442,30 @@ export function upstreamHandleFromProfileField(options: {
|
|
|
431
442
|
readonly hosts: readonly string[];
|
|
432
443
|
/** Fixed path segments before the handle (`bsky.app/profile/<handle>` ⇒ `['profile']`). */
|
|
433
444
|
readonly pathPrefix?: readonly string[];
|
|
445
|
+
/** Opt-in for the exact BirdsiteLive double-HTTPS serialization defect. */
|
|
446
|
+
readonly repairRepeatedHttpsScheme?: boolean;
|
|
447
|
+
/** Require the named assertion's link to explicitly carry rel=me. */
|
|
448
|
+
readonly requireRelMe?: boolean;
|
|
434
449
|
}): BridgeDerivation {
|
|
435
450
|
const wanted = options.fieldName.toLowerCase();
|
|
436
451
|
const prefix = options.pathPrefix ?? [];
|
|
437
452
|
return (candidate) => {
|
|
453
|
+
const handles = new Map<string, string>();
|
|
438
454
|
for (const field of candidate.fields) {
|
|
439
455
|
if (field.name.trim().toLowerCase() !== wanted) continue;
|
|
440
|
-
for (const href of fieldHrefs(field.value)) {
|
|
441
|
-
|
|
442
|
-
|
|
456
|
+
for (const href of fieldHrefs(field.value, options.requireRelMe)) {
|
|
457
|
+
// Observed bird.makeup Official assertion, 2026-09-13:
|
|
458
|
+
// https://https://twitter.com/jordievole. Repair exactly one duplicated
|
|
459
|
+
// HTTPS prefix, then run the ordinary host/path/credential validation.
|
|
460
|
+
const candidateHref = options.repairRepeatedHttpsScheme && href.startsWith('https://https://')
|
|
461
|
+
? href.slice('https://'.length) : href;
|
|
462
|
+
let handle: string | undefined;
|
|
463
|
+
try { handle = profileUrlHandle(candidateHref, options.hosts, prefix); } catch { continue; }
|
|
464
|
+
if (handle !== undefined && handle.length > 0) handles.set(handle.toLowerCase(), handle);
|
|
443
465
|
}
|
|
444
466
|
}
|
|
445
|
-
|
|
467
|
+
// Conflicting source assertions cannot be settled by document order.
|
|
468
|
+
return handles.size === 1 ? handles.values().next().value : undefined;
|
|
446
469
|
};
|
|
447
470
|
}
|
|
448
471
|
|