From e366626462b27d6f4dfeb92a5f7c4b9800636d53 Mon Sep 17 00:00:00 2001 From: wouter Date: Fri, 17 Jul 2026 02:37:32 +0200 Subject: [PATCH 1/2] fix: support legacy Matrix user IDs --- packages/mxfx/src/branded/user-id.test.ts | 18 +++++++++++++++++- packages/mxfx/src/branded/user-id.ts | 19 +++++++++++++++++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/mxfx/src/branded/user-id.test.ts b/packages/mxfx/src/branded/user-id.test.ts index c097497..939c751 100644 --- a/packages/mxfx/src/branded/user-id.test.ts +++ b/packages/mxfx/src/branded/user-id.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from '@effect/vitest' -import { Effect, Exit } from 'effect' +import { Effect, Exit, Schema } from 'effect' import { UserId } from './user-id.ts' @@ -24,4 +24,20 @@ describe('branded', () => { expect(Exit.isFailure(yield* Effect.exit(UserId.make(veryLongUserId)))).toBeTruthy() }), ) + + it.effect('should decode historical UserIds received from a server', () => + Effect.gen(function* () { + const decode = Schema.decodeUnknownEffect(UserId.schema) + + expect(yield* decode('@CapitalizedUserId:matrix.org')).toBe('@CapitalizedUserId:matrix.org') + expect(yield* decode('@legacy~user:matrix.org')).toBe('@legacy~user:matrix.org') + }), + ) + + it.effect('should not create new UserIds using the historical grammar', () => + Effect.gen(function* () { + expect(Exit.isFailure(yield* Effect.exit(UserId.make('@CapitalizedUserId:matrix.org')))).toBeTruthy() + expect(Exit.isFailure(yield* Effect.exit(UserId.make('@legacy~user:matrix.org')))).toBeTruthy() + }), + ) }) diff --git a/packages/mxfx/src/branded/user-id.ts b/packages/mxfx/src/branded/user-id.ts index aee6518..cecba12 100644 --- a/packages/mxfx/src/branded/user-id.ts +++ b/packages/mxfx/src/branded/user-id.ts @@ -5,10 +5,25 @@ import { ServerName } from './server-name.ts' const localpart = opaqueId.check(Schema.isPattern(/^[a-z0-9._=\-/+]+$/)) -const schema = Schema.TemplateLiteral(['@', localpart, ':', ServerName.schema]).pipe( +/** + * User IDs received from Matrix servers may use the historical, opaque localpart + * grammar. Homeservers must continue accepting these IDs even though they may not + * create new IDs using that grammar. + */ +const schema = Schema.TemplateLiteral(['@', opaqueId, ':', ServerName.schema]).pipe( + Schema.check(Schema.isMaxLength(255)), + Schema.brand('mxfx/UserId'), +) + +/** The modern grammar used when constructing a new user ID in mxfx. */ +const creationSchema = Schema.TemplateLiteral(['@', localpart, ':', ServerName.schema]).pipe( Schema.check(Schema.isMaxLength(255)), Schema.brand('mxfx/UserId'), ) export type UserId = typeof schema.Type -export const UserId = { schema, make: Schema.decodeUnknownEffect(schema) } +export const UserId = { + schema, + creationSchema, + make: Schema.decodeUnknownEffect(creationSchema), +} From 362e6facf523fc2d943bbcc7de14801cfbf08d2c Mon Sep 17 00:00:00 2001 From: wouter Date: Fri, 17 Jul 2026 02:47:57 +0200 Subject: [PATCH 2/2] fix: validate historical user IDs --- packages/mxfx/src/branded/user-id.test.ts | 26 +++++++++++++++++++++++ packages/mxfx/src/branded/user-id.ts | 14 +++++++++--- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/packages/mxfx/src/branded/user-id.test.ts b/packages/mxfx/src/branded/user-id.test.ts index 939c751..2e5a3b2 100644 --- a/packages/mxfx/src/branded/user-id.test.ts +++ b/packages/mxfx/src/branded/user-id.test.ts @@ -29,8 +29,34 @@ describe('branded', () => { Effect.gen(function* () { const decode = Schema.decodeUnknownEffect(UserId.schema) + expect(yield* decode('@:matrix.org')).toBe('@:matrix.org') expect(yield* decode('@CapitalizedUserId:matrix.org')).toBe('@CapitalizedUserId:matrix.org') expect(yield* decode('@legacy~user:matrix.org')).toBe('@legacy~user:matrix.org') + expect(yield* decode('@\u00e9:matrix.org')).toBe('@\u00e9:matrix.org') + expect(yield* decode('@\ud83d\ude00:matrix.org')).toBe('@\ud83d\ude00:matrix.org') + }), + ) + + it.effect('should reject invalid Unicode in historical UserIds', () => + Effect.gen(function* () { + const decode = Schema.decodeUnknownEffect(UserId.schema) + + expect(Exit.isFailure(yield* Effect.exit(decode('@\ud800:matrix.org')))).toBeTruthy() + expect(Exit.isFailure(yield* Effect.exit(decode('@\udfff:matrix.org')))).toBeTruthy() + expect(Exit.isFailure(yield* Effect.exit(decode('@legacy\u0000user:matrix.org')))).toBeTruthy() + }), + ) + + it.effect('should enforce the 255-byte UTF-8 limit for historical UserIds', () => + Effect.gen(function* () { + const decode = Schema.decodeUnknownEffect(UserId.schema) + const exactly255Bytes = `@${'\u00e9'.repeat(121)}a:matrix.org` + const moreThan255Bytes = `@${'\u00e9'.repeat(122)}:matrix.org` + + expect(new TextEncoder().encode(exactly255Bytes).byteLength).toBe(255) + expect(yield* decode(exactly255Bytes)).toBe(exactly255Bytes) + expect(new TextEncoder().encode(moreThan255Bytes).byteLength).toBe(256) + expect(Exit.isFailure(yield* Effect.exit(decode(moreThan255Bytes)))).toBeTruthy() }), ) diff --git a/packages/mxfx/src/branded/user-id.ts b/packages/mxfx/src/branded/user-id.ts index cecba12..4cd82e0 100644 --- a/packages/mxfx/src/branded/user-id.ts +++ b/packages/mxfx/src/branded/user-id.ts @@ -4,20 +4,28 @@ import { opaqueId } from './opaque-id.ts' import { ServerName } from './server-name.ts' const localpart = opaqueId.check(Schema.isPattern(/^[a-z0-9._=\-/+]+$/)) +const historicalLocalpart = Schema.String.check( + Schema.isPattern(/^[^:\uD800-\uDFFF]*$/u), + Schema.makeFilter((input: string) => !input.includes('\u0000'), { expected: 'a localpart without a NUL character' }), +) +const utf8Encoder = new TextEncoder() +const isValidUserIdByteLength = Schema.makeFilter((input: string) => utf8Encoder.encode(input).byteLength <= 255, { + expected: 'a user ID at most 255 bytes long when encoded as UTF-8', +}) /** * User IDs received from Matrix servers may use the historical, opaque localpart * grammar. Homeservers must continue accepting these IDs even though they may not * create new IDs using that grammar. */ -const schema = Schema.TemplateLiteral(['@', opaqueId, ':', ServerName.schema]).pipe( - Schema.check(Schema.isMaxLength(255)), +const schema = Schema.TemplateLiteral(['@', historicalLocalpart, ':', ServerName.schema]).pipe( + Schema.check(isValidUserIdByteLength), Schema.brand('mxfx/UserId'), ) /** The modern grammar used when constructing a new user ID in mxfx. */ const creationSchema = Schema.TemplateLiteral(['@', localpart, ':', ServerName.schema]).pipe( - Schema.check(Schema.isMaxLength(255)), + Schema.check(isValidUserIdByteLength), Schema.brand('mxfx/UserId'), )