Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
0a5d34d
feat(host-papp): add V2 SSO handshake codec, state machine, and pairi…
kalininilya May 20, 2026
3f64e60
docs(host-papp): document the V2 SSO handshake
kalininilya Apr 29, 2026
43163a6
feat(host-papp): carry identity chat priv key in V2 SSO success payload
kalininilya May 1, 2026
5c0514e
fix(host-papp): make V2 chat priv key optional for legacy PApp builds
kalininilya May 1, 2026
f5dfe43
feat(host-papp): replace V2 SSO encryption_key with identity_chat_pri…
kalininilya May 1, 2026
659413e
feat(host-papp): adopt multi-device HandshakeSuccessV2 v0.2.1 wire shape
kalininilya May 19, 2026
1d1c292
fix(host-papp,host-chat): tolerate camelCase Resources.Consumers fields
kalininilya May 19, 2026
72e0323
feat(host-chat): add multi-device chat content variants (indices 14–20)
kalininilya May 19, 2026
6bd3823
chore: refresh package-lock.json for new V2 SSO deps
kalininilya May 20, 2026
4c0fdc1
refactor(host-papp): drive V2 SSO inside createAuth, drop V1 handshake
kalininilya May 21, 2026
692943a
docs(changelog): reframe 0.8.0 entry against the 0.7.8 baseline
kalininilya May 21, 2026
2a21cfe
refactor(host-papp): absorb device-identity + persistence into the SD…
kalininilya May 21, 2026
a393468
feat: multi-device SSO V2 + chat V2 (#178)
kalininilya May 22, 2026
094e4ce
feat(host-chat): add paseo-next-v2 network config
kalininilya May 22, 2026
34b7ff4
docs: changelog
johnthecat May 22, 2026
9435d77
docs: migration guide
johnthecat May 22, 2026
802e844
chore(release): publish 0.8.0-0
johnthecat May 22, 2026
107c7fb
fix(host-chat): drop private flag so the package is publishable
kalininilya May 22, 2026
e6d6be8
docs(host-chat): replace stale host-container copy with a real README
kalininilya May 22, 2026
0c755a3
Merge branch 'release/0.8' into fix/publish-host-chat
johnthecat May 22, 2026
57db32c
docs: changelog
johnthecat May 22, 2026
e2fe5a6
feat(sso): support Mobile SSO spec v0.2.2 (sso_encr_pub_key) + clean …
kalininilya May 27, 2026
c3cda48
feat(host-chat): align chat-message + attachment codec with Android
kalininilya May 27, 2026
53b2a64
fix(handoff-service): sign canonical claim payload + expose hop_ack
kalininilya May 27, 2026
5ea314e
feat(host-papp): persist ssoEncPubKey on the stored session
kalininilya Jun 1, 2026
ebaa4fe
feat(host-papp): add watchIdentity subscription primitive
cuteWarmFrog May 27, 2026
e076f37
fix(host-papp): address code-review findings on watchIdentity
cuteWarmFrog May 27, 2026
30591d4
refactor(host-papp): simplify watchIdentity impl and tests
cuteWarmFrog May 27, 2026
03b5a32
fix(host-papp): address review on watchIdentity
cuteWarmFrog May 27, 2026
59f994e
fix(host-papp): deep-review fixes for watchIdentity stream
cuteWarmFrog May 27, 2026
4869c10
fix(host-papp): harden watchIdentity cache eviction; drop unused export
cuteWarmFrog May 27, 2026
3ee7e03
fix(host-papp): decode Resources.Consumers defensively (snake_case + …
kalininilya Jun 1, 2026
c1000f1
Merge branch 'main' into feat/sso-spec-v0.2.2
johnthecat Jun 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 53 additions & 9 deletions packages/host-chat/src/codec/attachment.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { describe, expect, it } from 'vitest';

import { FileMeta, FileVariant, GeneralFileMeta, ImageFileMeta, P2PMixnetFile, VideoFileMeta } from './attachment.js';
import {
FileMeta,
FileVariant,
GeneralFileMeta,
ImageFileMeta,
NodeEndpoint,
P2PMixnetFile,
VideoFileMeta,
} from './attachment.js';

describe('attachment codecs', () => {
describe('GeneralFileMeta', () => {
Expand All @@ -13,21 +21,33 @@ describe('attachment codecs', () => {
});

describe('ImageFileMeta', () => {
it('round-trips', () => {
it('round-trips without thumbnail', () => {
const original = {
general: { mimeType: 'image/jpeg', fileSize: 500_000 },
width: 1920,
height: 1080,
thumbnail: new TextEncoder().encode('LEHV6nWB2yk8pyo0adR*.7kCMdnj'),
thumbnail: undefined,
};
const encoded = ImageFileMeta.enc(original);
const decoded = ImageFileMeta.dec(encoded);
expect(decoded).toEqual(original);
});

it('round-trips with blurhash thumbnail', () => {
const original = {
general: { mimeType: 'image/jpeg', fileSize: 500_000 },
width: 1920,
height: 1080,
thumbnail: new TextEncoder().encode('L6PZfSi_.AyE_3t7t7R**0o#DgR4'),
};
const encoded = ImageFileMeta.enc(original);
const decoded = ImageFileMeta.dec(encoded);
expect(decoded.thumbnail).toEqual(original.thumbnail);
});
});

describe('VideoFileMeta', () => {
it('round-trips', () => {
it('round-trips without thumbnail', () => {
const original = {
general: { mimeType: 'video/mp4', fileSize: 10_000_000 },
duration: 120,
Expand All @@ -37,6 +57,17 @@ describe('attachment codecs', () => {
const decoded = VideoFileMeta.dec(encoded);
expect(decoded).toEqual(original);
});

it('round-trips with blurhash thumbnail', () => {
const original = {
general: { mimeType: 'video/mp4', fileSize: 10_000_000 },
duration: 120,
thumbnail: new TextEncoder().encode('LKO2?U%2Tw=w]~RBVZRi};RPxuwH'),
};
const encoded = VideoFileMeta.enc(original);
const decoded = VideoFileMeta.dec(encoded);
expect(decoded.thumbnail).toEqual(original.thumbnail);
});
});

describe('FileMeta', () => {
Expand All @@ -54,7 +85,11 @@ describe('attachment codecs', () => {
},
{
tag: 'video' as const,
value: { general: { mimeType: 'video/mp4', fileSize: 4096 }, duration: 60, thumbnail: undefined },
value: {
general: { mimeType: 'video/mp4', fileSize: 4096 },
duration: 60,
thumbnail: new TextEncoder().encode('blurhashstring'),
},
},
];

Expand All @@ -66,19 +101,28 @@ describe('attachment codecs', () => {
});
});

describe('NodeEndpoint', () => {
it('round-trips wssUrl', () => {
const original = { tag: 'wssUrl' as const, value: { url: 'wss://hop-a.example/chat' } };
const encoded = NodeEndpoint.enc(original);
const decoded = NodeEndpoint.dec(encoded);
expect(decoded).toEqual(original);
});
});

describe('P2PMixnetFile', () => {
it('round-trips', () => {
it('round-trips with nodeEndpoint and image thumbnail', () => {
const original = {
identifier: new Uint8Array(32).fill(0xaa),
claimTicket: new Uint8Array(32).fill(0xbb),
nodeEndpoint: { tag: 'wssUrl' as const, value: { url: 'wss://hop.example/ws' } },
nodeEndpoint: { tag: 'wssUrl' as const, value: { url: 'wss://bulletin.example/hop' } },
meta: {
tag: 'image' as const,
value: {
general: { mimeType: 'image/jpeg', fileSize: 500_000 },
width: 1920,
height: 1080,
thumbnail: undefined,
thumbnail: new TextEncoder().encode('L6PZfSi_.AyE_3t7t7R**0o#DgR4'),
},
},
};
Expand All @@ -98,7 +142,7 @@ describe('attachment codecs', () => {
value: {
identifier: new Uint8Array(32).fill(0x11),
claimTicket: new Uint8Array(32).fill(0x22),
nodeEndpoint: { tag: 'wssUrl' as const, value: { url: 'wss://hop.example/ws' } },
nodeEndpoint: { tag: 'wssUrl' as const, value: { url: 'wss://hop.example/path' } },
meta: { tag: 'general' as const, value: { mimeType: 'text/plain', fileSize: 10 } },
},
};
Expand Down
115 changes: 115 additions & 0 deletions packages/host-chat/src/codec/message.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest';

import {
CoinagePaymentContent,
DataChannelAnswerContent,
DataChannelClosedContent,
DataChannelIceCandidateContent,
DataChannelOfferContent,
MessageContent,
} from './message.js';

describe('DataChannel content codecs', () => {
it('DataChannelOfferContent round-trips with AUDIO_CALL purpose', () => {
const original = {
sdp: new TextEncoder().encode('v=0\r\no=- 4611...\r\n'),
purpose: 'AUDIO_CALL' as const,
};
const decoded = DataChannelOfferContent.dec(DataChannelOfferContent.enc(original));
expect(decoded.purpose).toBe('AUDIO_CALL');
expect(decoded.sdp).toEqual(original.sdp);
});

it('DataChannelAnswerContent round-trips', () => {
const original = { offerMessageId: 'offer-1', sdp: new Uint8Array([0xaa, 0xbb, 0xcc]) };
const decoded = DataChannelAnswerContent.dec(DataChannelAnswerContent.enc(original));
expect(decoded).toEqual(original);
});

it('DataChannelIceCandidateContent round-trips', () => {
const original = { offerMessageId: 'offer-1', sdp: new Uint8Array([0xde, 0xad, 0xbe, 0xef]) };
const decoded = DataChannelIceCandidateContent.dec(DataChannelIceCandidateContent.enc(original));
expect(decoded).toEqual(original);
});

it('DataChannelClosedContent round-trips', () => {
const original = { offerMessageId: 'offer-1' };
const decoded = DataChannelClosedContent.dec(DataChannelClosedContent.enc(original));
expect(decoded).toEqual(original);
});
});

describe('CoinagePaymentContent', () => {
it('round-trips with a small Balance and a couple of coin keys', () => {
// scale-ts `compact` decodes to `number` for values ≤ 2^30 and `bigint`
// for larger ones; normalise to BigInt before asserting.
const original = {
totalValue: 1_234_567,
coinKeys: [new Uint8Array(32).fill(0x11), new Uint8Array(32).fill(0x22)],
};
const decoded = CoinagePaymentContent.dec(CoinagePaymentContent.enc(original));
expect(BigInt(decoded.totalValue)).toBe(1_234_567n);
expect(decoded.coinKeys).toHaveLength(2);
expect(decoded.coinKeys[0]).toEqual(original.coinKeys[0]);
expect(decoded.coinKeys[1]).toEqual(original.coinKeys[1]);
});

it('round-trips a large Balance (forces bigint path)', () => {
const huge = 2n ** 100n;
const original = {
totalValue: huge,
coinKeys: [new Uint8Array(32).fill(0x33)],
};
const decoded = CoinagePaymentContent.dec(CoinagePaymentContent.enc(original));
expect(BigInt(decoded.totalValue)).toBe(huge);
});

it('round-trips with an empty coin-keys list', () => {
const original = { totalValue: 0, coinKeys: [] };
const decoded = CoinagePaymentContent.dec(CoinagePaymentContent.enc(original));
expect(BigInt(decoded.totalValue)).toBe(0n);
expect(decoded.coinKeys).toEqual([]);
});
});

describe('MessageContent enum discriminants', () => {
it('coinagePayment lands on discriminant 16 and round-trips inside MessageContent', () => {
const value = {
tag: 'coinagePayment' as const,
value: { totalValue: 42, coinKeys: [new Uint8Array(32).fill(0xab)] },
};
const encoded = MessageContent.enc(value);
expect(encoded[0]).toBe(16);
const decoded = MessageContent.dec(encoded);
expect(decoded.tag).toBe('coinagePayment');
if (decoded.tag !== 'coinagePayment') throw new Error('unreachable');
expect(BigInt(decoded.value.totalValue)).toBe(42n);
expect(decoded.value.coinKeys[0]).toEqual(value.value.coinKeys[0]);
});

it('dataChannelClosed lands on discriminant 11 and round-trips inside MessageContent', () => {
const value = {
tag: 'dataChannelClosed' as const,
value: { offerMessageId: 'offer-x' },
};
const encoded = MessageContent.enc(value);
expect(encoded[0]).toBe(11);
const decoded = MessageContent.dec(encoded);
expect(decoded.tag).toBe('dataChannelClosed');
if (decoded.tag !== 'dataChannelClosed') throw new Error('unreachable');
expect(decoded.value.offerMessageId).toBe('offer-x');
});

it('dataChannelOffer lands on discriminant 8 and round-trips inside MessageContent', () => {
const value = {
tag: 'dataChannelOffer' as const,
value: { sdp: new Uint8Array([1, 2, 3]), purpose: 'VIDEO_CALL' as const },
};
const encoded = MessageContent.enc(value);
expect(encoded[0]).toBe(8);
const decoded = MessageContent.dec(encoded);
if (decoded.tag !== 'dataChannelOffer') throw new Error('unreachable');
expect(decoded.value.purpose).toBe('VIDEO_CALL');
expect(decoded.value.sdp).toEqual(value.value.sdp);
});
});
15 changes: 12 additions & 3 deletions packages/host-papp/src/identity/rpcAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,19 +22,28 @@ function decodeRawIdentity(
): Identity | null {
if (!raw) return null;

// Runtime metadata may expose fields in snake_case (V1) or camelCase (V2
// multi-device). The .papi descriptor only types snake_case, so widen here
// and read defensively — otherwise the V2 multi-device runtime decodes to an
// empty username (the "Unknown user" regression).
const wide = raw as unknown as Record<string, unknown> & typeof raw;
const fullUsername = (wide.full_username as Uint8Array | undefined) ?? (wide.fullUsername as Uint8Array | undefined);
const liteUsername = (wide.lite_username as Uint8Array | undefined) ?? (wide.liteUsername as Uint8Array | undefined);

const credibility: Credibility =
raw.credibility.type === 'Lite'
? { type: 'Lite' }
: {
type: 'Person',
alias: raw.credibility.value.alias as HexString,
lastUpdate: raw.credibility.value.last_update.toString(),
lastUpdate: ((raw.credibility.value as Record<string, unknown>).last_update ??
(raw.credibility.value as Record<string, unknown>).lastUpdate)!.toString(),
};

return {
accountId,
fullUsername: raw.full_username ? textDecoder.decode(raw.full_username) : null,
liteUsername: textDecoder.decode(raw.lite_username),
fullUsername: fullUsername ? textDecoder.decode(fullUsername) : null,
liteUsername: liteUsername ? textDecoder.decode(liteUsername) : '',
credibility,
};
}
Expand Down