Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions typescript/ts_sdk/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
- Add durable, restart-safe idempotency state for text and attachment sends.
- Add strict JSON-RPC, message acknowledgement, history membership, attachment ticket, digest, and size validation.
- Add explicit User Service domain, public Message Service endpoint and DID, attachment origin allowlist, and attachment size limit configuration.
- Add public WNS display-name reads and updates, persist refreshed direct-peer profiles, and expose sender display names for group history.
- Add conversation unread counts and newest-message previews, including bounded Legacy group-history refresh and read acknowledgement.
- Make client disposal abort network work and join every operation that already entered the client before settling.
- Export `createAwikiImClient`, `AwikiImError`, and all AWiki IM public types from the package root.

Expand Down
6 changes: 4 additions & 2 deletions typescript/ts_sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,9 @@ Low-level Rust-aligned names are still exported for compatibility.
### im

- Register and restore one AWiki identity
- List persisted known direct conversations, current unread senders, and existing groups
- Read and update the identity's public WNS display name
- Resolve a direct peer's latest WNS display name and persist it with the known conversation; later history reads preserve that refreshed Profile instead of replacing it with an older per-message name snapshot
- List persisted known direct conversations, current unread senders, and existing groups with unread counts and newest-message previews
- Read paginated history and send idempotent text messages
- Upload, send, download, and verify one P7 attachment per message

Expand All @@ -156,7 +158,7 @@ Attachments are limited by `attachmentMaxBytes`, use transport protection with `

Text and attachment idempotency keys are persisted with a request fingerprint, fixed wire IDs, fixed timestamps, progress stage, and completed public result. Reusing a key for a different request fails with `conflict`. After restart or response loss, attachment upload resumes from its durable stage; a committed-slot retry does not upload the object again.

`listConversations()` combines durable conversations already seen by this client with the current unread inbox and all current groups. A fresh Legacy installation cannot reconstruct every previously read direct conversation because the Legacy service does not expose a complete direct-conversation roster.
`listConversations()` combines durable conversations already seen by this client with the current unread inbox and all current groups. Each observed conversation can carry a display-only newest-message preview: text is preserved, while image and file messages use a type-and-filename label. Because the Legacy group roster omits message summaries, each list refresh reads the newest bounded Group history page, updates its preview and timestamp, and supplements unread state for newly observed incoming Group messages that the Legacy inbox omitted. Opening that Group clears the in-memory supplemental count; durable cross-restart Group read state remains outside the Legacy API. A fresh Legacy installation cannot reconstruct every previously read direct conversation because the Legacy service does not expose a complete direct-conversation roster.

`getHistory()` returns each page in ascending timestamp order. A call without a cursor returns the newest service page; its opaque cursor is bound to the conversation and requests the next older page. The terminal page has `hasMore: false` and no high-water cursor. Legacy history uses offset pagination, so concurrent new deliveries can shift offsets; callers should deduplicate by message ID when merging pages. For refresh, call without a cursor and merge the newest page by message ID.

Expand Down
31 changes: 30 additions & 1 deletion typescript/ts_sdk/src/im/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@ import { AwikiMessagingRuntime } from './messaging.js';
import { AwikiImTransport, validateServiceBaseUrl } from './protocol.js';
import { AwikiImStateStore } from './storage.js';
import type {
AwikiDid,
AwikiHandle,
AwikiIdentity,
AwikiImClient,
AwikiResolvedPeer,
AwikiImClientOptions,
AwikiPageRequest,
AwikiPage,
AwikiConversation,
AwikiConversationId,
GetAwikiHistoryRequest,
AwikiMessage,
SendAwikiTextRequest,
Expand All @@ -20,6 +24,7 @@ import type {
RegisterIdentityRequest,
SendRegistrationOtpRequest,
SendRegistrationOtpResult,
UpdateAwikiDisplayNameRequest,
} from './types.js';

/** Create one high-level Node.js AWiki IM client. */
Expand Down Expand Up @@ -98,7 +103,10 @@ class DefaultAwikiImClient implements AwikiImClient {
}

public async getIdentity(): Promise<AwikiIdentity | null> {
return this.run(() => Promise.resolve(structuredClone(this.identity.getIdentity())));
return this.run(async () => {
await this.identity.hydrateDisplayName();
return structuredClone(this.identity.getIdentity());
});
}

public async sendRegistrationOtp(
Expand All @@ -111,6 +119,23 @@ class DefaultAwikiImClient implements AwikiImClient {
return this.run(() => this.identity.registerIdentity(request));
}

public async updateDisplayName(request: UpdateAwikiDisplayNameRequest): Promise<AwikiIdentity> {
return this.run(() => this.identity.updateDisplayName(request.displayName));
}

public async resolvePeer(peer: string): Promise<AwikiResolvedPeer> {
return this.run(async () => {
this.identity.requireSecrets();
const resolved = await this.messaging.resolveTarget({ kind: 'direct', peer });
return {
did: resolved.did as AwikiDid,
conversationId: resolved.conversationId,
...(resolved.handle === undefined ? {} : { handle: resolved.handle as AwikiHandle }),
...(resolved.displayName === undefined ? {} : { displayName: resolved.displayName }),
};
});
}

public async listConversations(
request?: AwikiPageRequest
): Promise<AwikiPage<AwikiConversation>> {
Expand All @@ -121,6 +146,10 @@ class DefaultAwikiImClient implements AwikiImClient {
return this.run(() => this.messaging.getHistory(request));
}

public async markConversationRead(conversationId: AwikiConversationId): Promise<number> {
return this.run(() => this.messaging.markConversationRead(conversationId));
}

public async sendText(request: SendAwikiTextRequest): Promise<AwikiMessage> {
return this.run(() => this.messaging.sendText(request));
}
Expand Down
90 changes: 90 additions & 0 deletions typescript/ts_sdk/src/im/display-name.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/** WNS display-name lookup used by identity and conversation projection. */

import type { AwikiImTransport } from './protocol.js';

/** Split a Handle into the WNS local part and domain. */
export function parseHandle(
value: string
): { local: string; domain: string; handle: string } | undefined {
const trimmed = value
.trim()
.replace(/^@+/u, '')
.replace(/^wba:\/\//u, '');
const separator = trimmed.indexOf('.');
if (separator <= 0 || separator === trimmed.length - 1) {
return undefined;
}
const local = trimmed.slice(0, separator);
const domain = trimmed.slice(separator + 1);
if (!local || !domain) {
return undefined;
}
return { local, domain, handle: `${local}.${domain}` };
}

/** Infer a Handle from a `did:wba` path when inbox rows omit one. */
export function handleCandidateFromDid(did: string): string | undefined {
if (!did.startsWith('did:wba:')) {
return undefined;
}
const parts = did.split(':');
if (parts.length < 4) {
return undefined;
}
const domain = parts[2];
const path = parts.slice(3);
const local = path[0] === 'user' ? path[1] : path[0];
if (!domain || !local || /^(?:e1_|k1_)/u.test(local)) {
return undefined;
}
return `${local}.${domain}`;
}

/** Read a public WNS profile and return its display-only name. */
export async function lookupDisplayName(
transport: AwikiImTransport,
handle: string,
expectedDid?: string
): Promise<string | undefined> {
const parsed = parseHandle(handle);
if (!parsed) {
return undefined;
}
try {
const document = await transport.getJson(
`https://${parsed.domain}/.well-known/handle/${encodeURIComponent(parsed.local)}`
);
if (stringValue(document.handle)?.toLowerCase() !== parsed.handle.toLowerCase()) {
return undefined;
}
if (expectedDid && stringValue(document.did) !== expectedDid) {
return undefined;
}
const profile = isRecord(document.profile) ? document.profile : undefined;
const displayName = stringValue(document.display_name) ?? stringValue(profile?.display_name);
if (!displayName) {
return undefined;
}
if (profile) {
const subjectDid = stringValue(profile.subject_did);
const profileHandle = stringValue(profile.handle);
if (subjectDid && expectedDid && subjectDid !== expectedDid) {
return undefined;
}
if (profileHandle && profileHandle.toLowerCase() !== parsed.handle.toLowerCase()) {
return undefined;
}
}
return displayName;
} catch {
return undefined;
}
}

function stringValue(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}

function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
98 changes: 97 additions & 1 deletion typescript/ts_sdk/src/im/identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ import { AwikiImError, normalizeAwikiImError } from './errors.js';
import { type PendingRegistrationState, type PersistedIdentitySecrets } from './internal.js';
import {
DID_AUTH_RPC_PATH,
DID_PROFILE_RPC_PATH,
HANDLE_RPC_PATH,
randomChallenge,
type AwikiImTransport,
} from './protocol.js';
import { lookupDisplayName } from './display-name.js';
import type { AwikiImStateStore } from './storage.js';
import type {
AwikiDid,
Expand All @@ -36,6 +38,8 @@ interface IdentityRuntimeOptions {
/** Identity registration and token refresh operations for the high-level client. */
export class AwikiIdentityRuntime {
private registrationTail: Promise<void> = Promise.resolve();
private profileTail: Promise<void> = Promise.resolve();
private displayNameResolved = false;

public constructor(private readonly options: IdentityRuntimeOptions) {}

Expand All @@ -44,6 +48,31 @@ export class AwikiIdentityRuntime {
return this.options.store.snapshot().identity?.public ?? null;
}

/** Fill a missing WNS display name once; failures leave the identity unchanged. */
public async hydrateDisplayName(): Promise<void> {
const current = this.getIdentity();
if (!current || current.displayName !== undefined || this.displayNameResolved) {
return;
}
this.displayNameResolved = true;
const displayName = await lookupDisplayName(
this.options.transport,
current.handle,
current.did
);
if (!displayName) {
return;
}
await this.options.store.mutate((state) => {
if (state.identity) {
state.identity = {
...state.identity,
public: { ...state.identity.public, displayName },
};
}
});
}

/** Reject persisted identity material that belongs to a different configured deployment. */
public validateConfiguredIdentity(): void {
const snapshot = this.options.store.snapshot();
Expand Down Expand Up @@ -149,6 +178,58 @@ export class AwikiIdentityRuntime {
});
}

/** Update the public WNS display name and keep the local projection in sync. */
public async updateDisplayName(value: string): Promise<AwikiIdentity> {
return this.exclusiveProfileUpdate(async () => {
const displayName = value.trim();
const length = [...displayName].length;
if (length === 0 || length > 50) {
throw new AwikiImError(
'invalid-request',
'AWiki display name must contain between 1 and 50 characters'
);
}
let identity = this.requireSecrets();
let result;
try {
result = await this.options.transport.rpc(
this.options.userServiceUrl,
DID_PROFILE_RPC_PATH,
'update_me',
{ nick_name: displayName },
identity.accessToken
);
} catch (error) {
const normalized = normalizeAwikiImError(error);
if (normalized.code !== 'forbidden') throw normalized;
const accessToken = await this.refreshAccessToken();
identity = this.requireSecrets();
result = await this.options.transport.rpc(
this.options.userServiceUrl,
DID_PROFILE_RPC_PATH,
'update_me',
{ nick_name: displayName },
accessToken
);
}
const returnedName = requiredWireString(result.value.display_name, 'display_name');
if (returnedName !== displayName) {
throw new AwikiImError('remote', 'AWiki service returned an invalid response');
}
await this.options.store.mutate((state) => {
if (state.identity) {
state.identity = {
...state.identity,
...(result.accessToken === undefined ? {} : { accessToken: result.accessToken }),
public: { ...state.identity.public, displayName: returnedName },
};
}
});
this.displayNameResolved = true;
return this.getIdentity() ?? identity.public;
});
}

/** Require secret identity material for a message operation. */
public requireSecrets(): PersistedIdentitySecrets {
const identity = this.options.store.snapshot().identity;
Expand Down Expand Up @@ -257,7 +338,8 @@ export class AwikiIdentityRuntime {
delete state.registrationOtp;
delete state.pendingRegistration;
});
return publicIdentity;
await this.hydrateDisplayName();
return this.getIdentity() ?? publicIdentity;
}

private async exclusiveRegistration<T>(operation: () => Promise<T>): Promise<T> {
Expand All @@ -273,6 +355,20 @@ export class AwikiIdentityRuntime {
release();
}
}

private async exclusiveProfileUpdate<T>(operation: () => Promise<T>): Promise<T> {
let release: () => void = () => undefined;
const previous = this.profileTail;
this.profileTail = new Promise<void>((resolve) => {
release = resolve;
});
await previous;
try {
return await operation();
} finally {
release();
}
}
}

function createPendingRegistration(
Expand Down
Loading