Skip to content

Conversation

@54m
Copy link

@54m 54m commented Jan 9, 2026

Summary

Implements client-side UI and logic for session sharing functionality, including shared session fetching, decryption, and management interface.

Features

Session Sharing Management UI

  • Manage sharing button in session info screen
  • Direct sharing dialog with friend selector
  • Access level selection (view/edit/admin)
  • Real-time share list with current access levels
  • Owner permission checks

Shared Session Support

  • Fetch sessions shared with current user
  • Decrypt encryptedDataKey using recipient's private key
  • Initialize shared sessions with proper encryption
  • Real-time updates for share events
  • Automatic session removal on revoke

Client-Side Decryption

  • Uses existing Box decryption (X25519-XSalsa20-Poly1305)
  • Decrypts encryptedDataKey with user's private key
  • Maintains E2E encryption throughout
  • Session data remains encrypted at rest

Real-time Events

  • session-shared: New share notification
  • session-share-updated: Access level changes
  • session-share-revoked: Share removal and cleanup

Security

  • Server-side encryption with recipient's public key
  • Client-side decryption with private key
  • No plaintext data keys transmitted
  • Owner-only permission enforcement

Implementation Details

New Components

  • SessionShareDialog: Share management interface
  • FriendSelector: Friend selection with search
  • PublicLinkDialog: Public link UI (marked as not implemented)

API Integration

  • Server-side encryption removes client encryption requirement
  • API interfaces updated to match backend
  • Proper error handling with HappyError

Translation Support

  • Added translations for all UI strings
  • Supported languages: en, ru, pl, es, ca, it, pt, ja, zh-Hans

Technical Changes

Files modified:

  • sources/sync/sync.ts: Shared session fetching and decryption
  • sources/sync/apiSharing.ts: API client updates
  • sources/sync/sharingTypes.ts: Type definitions
  • sources/sync/friendTypes.ts: Added publicKey field
  • sources/components/SessionSharing/: UI components
  • sources/app/(app)/session/[id]/: Sharing management screens
  • sources/text/: Translations

Related PRs

Backend PR: slopus/happy-server#25

Notes

Public link functionality is marked as not implemented pending encryption design for anonymous access.

54m added 30 commits January 9, 2026 16:18
Add comprehensive translations for session sharing feature including:
- Session sharing UI labels and actions
- Access level descriptions (view, edit, manage)
- Public link management
- Consent and access logging
- Error messages for sharing operations

Translations added for all 9 supported languages:
- English, Japanese, Russian, Polish, Spanish
- Portuguese, Chinese (Simplified), Italian, Catalan

- sources/text/_default.ts
- sources/text/translations/ja.ts
- sources/text/translations/ru.ts
- sources/text/translations/pl.ts
- sources/text/translations/es.ts
- sources/text/translations/pt.ts
- sources/text/translations/zh-Hans.ts
- sources/text/translations/it.ts
- sources/text/translations/ca.ts
Add comprehensive TypeScript types and API client for session sharing:

Types (`sharingTypes.ts`):
- ShareAccessLevel: view/edit/admin permissions
- SessionShare: Direct user-to-user sharing
- PublicSessionShare: Link-based public sharing
- Request/Response types for all API operations
- Custom error classes for error handling
- Detailed TSDoc for all types and interfaces

API Client (`apiSharing.ts`):
- Direct sharing: create, update, delete, list shares
- Public links: create, delete, access, manage
- Shared sessions: list and retrieve
- Access logs and blocked users management
- Detailed TSDoc with @param, @returns, @throws tags
- Automatic retry with backoff on failures
- Type-safe error handling

- sources/sync/sharingTypes.ts
- sources/sync/apiSharing.ts
Implements SessionShareDialog for managing session sharing with users.
Displays current shares, access levels, and provides UI for adding/removing shares.

- sources/components/SessionSharing/SessionShareDialog.tsx
Adds translations for session sharing UI elements across all 9 supported languages.
Includes strings for access levels, share management, and dialog text.

- sources/text/_default.ts
- sources/text/translations/ca.ts
- sources/text/translations/es.ts
- sources/text/translations/it.ts
- sources/text/translations/ja.ts
- sources/text/translations/pl.ts
- sources/text/translations/pt.ts
- sources/text/translations/ru.ts
- sources/text/translations/zh-Hans.ts
Implements FriendSelector component for choosing friends to share sessions with.
Features searchable friend list and access level selection.

- sources/components/SessionSharing/FriendSelector.tsx
Adds translation keys for friend search and selection in session sharing.
Includes searchFriends, noFriendsFound, and addShare across all languages.

- sources/text/_default.ts
- sources/text/translations/ca.ts
- sources/text/translations/es.ts
- sources/text/translations/it.ts
- sources/text/translations/ja.ts
- sources/text/translations/pl.ts
- sources/text/translations/pt.ts
- sources/text/translations/ru.ts
- sources/text/translations/zh-Hans.ts
Implements PublicLinkDialog for creating and managing public share links.
Features QR code generation, expiration settings, usage limits, and consent options.

- sources/components/SessionSharing/PublicLinkDialog.tsx
Adds translation keys for public link creation and management UI.
Includes expiration, usage limits, and consent options across all languages.

- sources/text/_default.ts
- sources/text/translations/ca.ts
- sources/text/translations/es.ts
- sources/text/translations/it.ts
- sources/text/translations/ja.ts
- sources/text/translations/pl.ts
- sources/text/translations/pt.ts
- sources/text/translations/ru.ts
- sources/text/translations/zh-Hans.ts
Align frontend with server-side encryption implementation. The server
now handles data key encryption automatically using recipient's public
keys. Clients no longer provide encryptedDataKey when creating shares.

Files:
- sources/sync/sharingTypes.ts
- sources/sync/apiSharing.ts
- sources/app/(app)/session/[id]/sharing.tsx
Add publicKey field to UserProfile for encryption support. Add getUserID
and getUserPublicKey helper methods to sync class for accessing user info.

Files:
- sources/sync/friendTypes.ts
- sources/sync/sync.ts
Add translations for session sharing UI including direct sharing,
public links, access levels, and error messages across all languages.

Files:
- sources/text/_default.ts
- sources/text/translations/ca.ts
- sources/text/translations/es.ts
- sources/text/translations/it.ts
- sources/text/translations/ja.ts
- sources/text/translations/pl.ts
- sources/text/translations/pt.ts
- sources/text/translations/ru.ts
- sources/text/translations/zh-Hans.ts
Add navigation button to session sharing management screen in the
quick actions section of session info page.

Files:
- sources/app/(app)/session/[id]/info.tsx
Implement client-side logic to fetch and decrypt sessions shared with
the current user. Uses existing Box decryption to decrypt encryptedDataKey
with recipient's private key, enabling E2E encrypted session sharing.

Files:
- sources/sync/sync.ts
Add token-based encryption for public shares with client-side key
generation. Clients generate random tokens, encrypt data keys with
derived keys, and send both to server for E2E security.

Files:
- sources/sync/publicShareEncryption.ts
- sources/sync/sync.ts
- sources/sync/apiSharing.ts
- sources/app/(app)/session/[id]/sharing.tsx
Store owner profile information and access level for shared sessions.
This enables UI to display who shared the session and enforce permissions.

- sources/sync/storageTypes.ts
- sources/sync/sync.ts
Add translations for view-only mode and permission error messages.
Covers all supported languages for access control feedback.

- sources/text/_default.ts
- sources/text/translations/ca.ts
- sources/text/translations/es.ts
- sources/text/translations/it.ts
- sources/text/translations/ja.ts
- sources/text/translations/pl.ts
- sources/text/translations/pt.ts
- sources/text/translations/ru.ts
- sources/text/translations/zh-Hans.ts
Show owner badge with icon and name for shared sessions.
Helps users quickly identify which sessions are shared with them.

- sources/components/ActiveSessionsGroup.tsx
Support disabling input field and send button for read-only sessions.
Implements UI enforcement of view-only access level.

- sources/components/AgentInput.tsx
Disable input and block message sending for view-only sessions.
Show appropriate placeholder and error messages based on access level.

- sources/-session/SessionView.tsx
Hide sharing management button for non-admin access levels.
Only session owners and admin-level users can modify sharing settings.

- sources/app/(app)/session/[id]/info.tsx
Add method to store decrypted data keys from public shares.
Expose server URL getter for public share API access.

- sources/sync/sync.ts
- sources/sync/publicShareEncryption.ts
Add translations for share access errors and consent flow.
Covers not found, expired, decryption failure, and consent UI.

- sources/text/_default.ts
- sources/text/translations/ca.ts
- sources/text/translations/es.ts
- sources/text/translations/it.ts
- sources/text/translations/ja.ts
- sources/text/translations/pl.ts
- sources/text/translations/pt.ts
- sources/text/translations/ru.ts
- sources/text/translations/zh-Hans.ts
Add screen for accessing sessions via public share links.
Handles token-based decryption and consent flow with owner display.

- sources/app/(app)/share/[token].tsx
Use username as primary display name with firstName as fallback.
Maintains consistency across user profile displays.

- sources/components/ActiveSessionsGroup.tsx
Remove non-existent CustomModal dependency from FriendSelector and PublicLinkDialog.
Convert to standard View components without modal wrapper logic.

- sources/components/SessionSharing/FriendSelector.tsx
- sources/components/SessionSharing/PublicLinkDialog.tsx
Updated theme property references to match existing codebase patterns.
Changed background->surface, typography->text, primary->textLink, margins to fixed pixel values.
Applied consistent radio button styling using theme.colors.radio.

- sources/components/SessionSharing/SessionShareDialog.tsx
- sources/components/SessionSharing/FriendSelector.tsx
- sources/components/SessionSharing/PublicLinkDialog.tsx
Added translation keys for share error messages, public link options, and consent flow.
Includes shareNotFound, shareExpired, failedToDecrypt, and various UI labels.

- sources/text/_default.ts
Fixed component props to match actual interfaces (Avatar, Item).
Updated translation key format from sessionSharing to session.sharing.
Prioritized username display over firstName+lastName concatenation.

- sources/app/(app)/session/[id]/sharing.tsx
- sources/app/(app)/share/[token].tsx
Added missing translation keys for session sharing feature.
Includes error messages, public link options, and consent flow strings.

- sources/text/translations/ru.ts
- sources/text/translations/pl.ts
- sources/text/translations/es.ts
- sources/text/translations/pt.ts
- sources/text/translations/ca.ts
- sources/text/translations/it.ts
- sources/text/translations/ja.ts
- sources/text/translations/zh-Hans.ts
Added remaining translation keys for public link options, error messages, and consent flow.
Includes expiration options, usage limits, and dynamic count functions.

- sources/text/translations/ru.ts
- sources/text/translations/pl.ts
- sources/text/translations/es.ts
- sources/text/translations/pt.ts
- sources/text/translations/ca.ts
- sources/text/translations/it.ts
- sources/text/translations/ja.ts
- sources/text/translations/zh-Hans.ts
54m added 5 commits January 10, 2026 12:12
Fixed parameter names in translation function calls.
Changed count->used for usage count display functions.
Changed common.close->common.cancel for consistency.
Removed non-existent ownerUsername property reference.

- sources/app/(app)/share/[token].tsx
- sources/components/SessionSharing/PublicLinkDialog.tsx
- sources/components/SessionSharing/SessionShareDialog.tsx
Replaced `sync.getServerUrl` with direct `getServerUrl` call.
Removed redundant `getServerUrl` method from `sync`.

- sources/app/(app)/share/[token].tsx
- sources/sync/sync.ts
Remove unused Modal import from sharing screen

- sources/app/(app)/share/[token].tsx
Replaced `ItemGroup` with `ItemList` for better structure. Adjusted styles and removed unused `isCreating` state logic. Consolidated common sections and updated QR code dimensions for consistency.

- sources/components/SessionSharing/PublicLinkDialog.tsx
Updated icons in SessionShareDialog to use Ionicons for consistency and styling improvements. Adjusted size and color to align with design guidelines.

- sources/components/SessionSharing/SessionShareDialog.tsx
@54m 54m marked this pull request as draft January 10, 2026 04:39
54m added 2 commits January 10, 2026 20:33
Add three event schemas for real-time session sharing notifications. These schemas enable clients to receive updates when sessions are shared, share permissions are modified, or shares are revoked.

- sources/sync/apiTypes.ts
Replace plain Item component with RoundButton for the create button. Makes the primary action more visible and easier to recognize in the dialog.

- sources/components/SessionSharing/PublicLinkDialog.tsx
@54m 54m marked this pull request as ready for review January 10, 2026 13:51
@54m
Copy link
Author

54m commented Jan 10, 2026

@ex3ndr (Are you the correct person to mention?)
Since there is no EAS project, I cannot build it and therefore cannot check its operation.
The web version also has a problem with machine selection, so I cannot check the exact operation of the implementation.
Although I have not committed it, I have created a preview file and have only performed a simple operation check.
You may adjust the UI as needed. Thank you.

@54m 54m force-pushed the feature/session-sharing branch from 16af0c1 to 7213402 Compare January 10, 2026 14:20
54m added 2 commits January 10, 2026 23:21
delete: unnecessary file

delete: unnecessary file

delete: unnecessary file
@54m 54m force-pushed the feature/session-sharing branch from 7213402 to a7a2a6f Compare January 10, 2026 14:22
@bra1nDump
Copy link
Contributor

@ex3ndr is probably the best person to review this, but not sure if he has time at the moment

you can tag me or @leeroybrun (recently joined the maintainer team) or @GrocerPublishAgent

this feature would be amazing to merge! and from the first glance the approach looks aligned with current encryption flow

@leeroybrun
Copy link
Collaborator

leeroybrun commented Jan 24, 2026

Hey @54m — thank you very much for tackling this. 👏 Session sharing is a big feature, and your PRs include a lot of valuable groundwork (DB schema, rate limiting, consent gating, real-time events, UI screens, translations, etc.).

That said, there are a few merge-blocking correctness + model-alignment issues we should address so this lands in a way that is fully coherent with Happy’s end‑to‑end encryption model and doesn’t introduce competing crypto/key flows.

Below is a detailed, actionable review covering both PRs:

  • UI: https://github.com/slopus/happy/pull/356/files
  • Server: https://github.com/slopus/happy-server/pull/25/files

0) The key constraint (Happy’s E2E model)

In Happy today, the server stores session content as encrypted blobs, and the session data encryption key (DEK) is stored/transported as an encrypted key blob that only the client can decrypt.

On the client, owned sessions do:

  • decrypt session.dataEncryptionKey via decryptEncryptionKey() (it expects a “version byte + NaCl box bundle”) and then use the plaintext DEK to decrypt metadata/agentState/messages.

See:

  • Current upstream (monorepo) client DEK decrypt call site: https://github.com/slopus/happy/blob/main/expo-app/sources/sync/sync.ts
  • Current upstream (monorepo) decryptEncryptionKey() format expectation: https://github.com/slopus/happy/blob/main/expo-app/sources/sync/encryption/encryption.ts

Implication: the server cannot “re-encrypt the session DEK for someone else” unless it can first decrypt it — and it cannot (and should not) do that in this model.

So the simplest E2E-consistent sharing design is:

  1. Owner client gets recipient contentPublicKey (box/X25519 public key)
  2. Owner client encrypts (wraps) the plaintext session DEK to recipient contentPublicKey (box/X25519 public key)
  3. Server stores that encrypted blob and enforces authorization
  4. Recipient decrypts the encrypted blob locally (using his key) to recover the session DEK

0.1) Proposed encryptedDataKey wire format (so UI + server match)

If you’d like, we can help by agreeing on the exact encryptedDataKey wire format so both repos implement the same thing and the recipient can reuse existing decryptEncryptionKey() logic.

✅ Proposed encryptedDataKey wire format (for direct shares)

Format (v0):

  • encryptedDataKey is a base64 string.
  • When decoded to bytes:
    • bytes = [0x00] || box_bundle
  • Where box_bundle is the same NaCl box bundle used elsewhere:
    • box_bundle = ephemeral_pk(32) || nonce(24) || ciphertext(...)

This matches the existing client decryptor:

  • Upstream main (monorepo): expo-app/sources/sync/encryption/encryption.tsdecryptEncryptionKey() expects a version byte + decryptBox(...).

Client-side reference implementation (Happy UI):

import { encryptBox } from '@/encryption/libsodium';
import { encodeBase64, decodeBase64 } from '@/encryption/base64';

// v0 = version byte 0 + encryptBox bundle, base64 encoded
export function encryptDataKeyForRecipientV0(
  sessionDataKey: Uint8Array,
  recipientContentPublicKeyB64: string,
): string {
  const recipientPub = decodeBase64(recipientContentPublicKeyB64, 'base64');
  const bundle = encryptBox(sessionDataKey, recipientPub);

  const out = new Uint8Array(1 + bundle.length);
  out[0] = 0;
  out.set(bundle, 1);

  return encodeBase64(out, 'base64');
}

Server-side minimum validation (Happy Server):

function parseEncryptedDataKeyV0(encryptedDataKeyB64: string): Uint8Array {
  const bytes = new Uint8Array(Buffer.from(encryptedDataKeyB64, 'base64'));
  if (bytes.length < 1 + 32 + 24 + 16) throw new Error('encryptedDataKey too short');
  if (bytes[0] !== 0) throw new Error('unsupported encryptedDataKey version');
  return bytes;
}

Key point: the server should store/forward encryptedDataKey bytes, not generate them (E2E). The recipient decrypts it locally with the existing flow.


1) Merge blockers — happy-server#25

1.1 Server-side “encrypt session data key for sharing” is not compatible with current model (and likely incorrect in practice)

In shareRoutes.ts, the server reads session.dataEncryptionKey from DB and encrypts it for the recipient:

  • https://github.com/slopus/happy-server/pull/25/files#diff-efc41587c16f21477f8a675f1f1831ea075d4b146923c639ce777e73032c9477 (see the POST /v1/sessions/:sessionId/shares handler)
    • specifically around recipientPublicKey = Buffer.from(targetUser.publicKey, 'base64') and then encryptDataKeyForRecipient(session.dataEncryptionKey, ...)

Problem:

  • session.dataEncryptionKey in Happy is already an encrypted blob (encrypted “to self”, by the client).
  • Encrypting that blob again does not produce the plaintext DEK for the recipient, and the server still never had the plaintext DEK to begin with.

Requested change

  • Change the API so the client sends encryptedDataKey (base64) in POST /v1/sessions/:sessionId/shares.
  • Server only stores it (opaque bytes) + accessLevel.
  • Remove server-side encryption of DEKs entirely (encryptDataKeyForRecipient becomes unnecessary for direct sharing).

Concrete change requested (server): accept encryptedDataKey from client, do not compute it

In happy-server PR diff:

  • sources/app/api/routes/shareRoutes.ts (https://github.com/slopus/happy-server/pull/25/files#diff-efc41587c16f21477f8a675f1f1831ea075d4b146923c639ce777e73032c9477)

Change POST /v1/sessions/:sessionId/shares to accept encryptedDataKey (base64 string) in the request body and store it as bytes:

schema: {
  body: z.object({
    userId: z.string(),
    accessLevel: z.enum(['view', 'edit', 'admin']),
    encryptedDataKey: z.string(), // base64(v0 + box bundle)
  }),
},

Then:

const encryptedDataKeyBytes = parseEncryptedDataKeyV0(request.body.encryptedDataKey);

await db.sessionShare.upsert({
  ...
  create: {
    ...
    encryptedDataKey: encryptedDataKeyBytes,
  },
  update: {
    ...
    encryptedDataKey: encryptedDataKeyBytes,
  },
});

…and remove the code that:

  • fetches session.dataEncryptionKey and
  • calls encryptDataKeyForRecipient(session.dataEncryptionKey, ...)

because that is server-side crypto on an already-encrypted blob and breaks the E2E model.

1.2 Public key encoding + key-type mismatch (signing key ≠ box/content key)

A) Encoding mismatch (hex vs base64) — immediate bug

Auth stores Account.publicKey as hex:

  • Current upstream (monorepo) auth routes: https://github.com/slopus/happy/blob/main/server/sources/app/api/routes/authRoutes.ts

But shareRoutes.ts treats targetUser.publicKey as base64:

  • https://github.com/slopus/happy-server/pull/25/files#diff-efc41587c16f21477f8a675f1f1831ea075d4b146923c639ce777e73032c9477

And user profile APIs return publicKey (currently as whatever is stored):

  • https://github.com/slopus/happy-server/pull/25/files#diff-dbd755047f437867d3f8d5485400650baae2a9145acb430b02d5a43775565217
  • https://github.com/slopus/happy-server/pull/25/files#diff-1c0c92f7e05690f4be5a945ae5917cf93f622869381a44c1e51774ecd6bf0e49

This will decode garbage bytes and makes any cryptography on top of it invalid.

B) More importantly: it’s the wrong key type for box encryption — design blocker

Right now, Account.publicKey is not a “content encryption” key. It is the auth signing key:

  • On the client (upstream monorepo), the auth keypair is Ed25519:
    • expo-app/sources/auth/authChallenge.ts uses sodium.crypto_sign_seed_keypair(secret).
  • On the client (upstream monorepo), session DEK wrapping/unwrapping uses a libsodium crypto_box keypair (X25519):
    • expo-app/sources/sync/encryption/encryption.ts derives contentKeyPair = sodium.crypto_box_seed_keypair(contentDataKey).
    • The decryptor expects a “version byte + box bundle” and then uses decryptBox(...).

But in the server PR, sharing encryption uses tweetnacl.box (X25519):

  • happy-server PR: sources/app/share/encryptDataKey.ts uses nacl.box(...).

So today the PR is mixing:

  • Ed25519 public keys (account auth identity) and
  • X25519 public keys (box/content encryption)

Those are not interchangeable. Even with the hex/base64 fix, tweetnacl.box still won’t work with an Ed25519 public key.

✅ Target model for sharing keys (what to implement)

We need two distinct public keys per account:

  1. signingPublicKey (Ed25519)
  • Used for authentication (what Account.publicKey already effectively is today).
  1. contentPublicKey / boxPublicKey (X25519 / crypto_box)
  • Used for encrypting session DEKs to recipients (NaCl box / libsodium crypto_box).

Recommendation (wire encoding): expose contentPublicKey to clients as base64.
DB encoding: store as bytes or hex, but consistently convert at the API boundary.


✅ Concrete changes requested

1) DB/schema: store the content/box public key

  • Add a new column/field on Account, e.g.:
    • contentPublicKey (Bytes preferred, or hex string)
  • Keep existing Account.publicKey as the auth/signing key (Ed25519).

2) Auth or profile flow: let the client register contentPublicKey

You need some authenticated way for clients to upload/refresh their X25519 content public key.
Two viable options:

Option A (simple): extend /v1/auth to accept it and store it

  • Extend request body with:
    • contentPublicKey: string (base64)
  • Store it on the account record (as bytes/hex).

Option B (cleaner separation): add a dedicated endpoint

  • POST /v1/account/keys (auth required)
    • Body includes contentPublicKey (base64)
  • Store/update the account record.

Note: The client already has the box/content public key as sync.encryption.contentDataKey (crypto_box public key). We just need a small authenticated API to upload it to the server and then expose it in /v1/friends + /v1/user/:id.

Strongly recommended hardening (either option):

  • Require a signature binding contentPublicKey to the authenticated identity key, e.g.:
    • contentPublicKeySig: string (base64)
    • Server verifies with Ed25519 Account.publicKey over ("Happy content key v1" || contentPublicKeyBytes)
      This prevents an attacker from swapping someone’s box key in the DB without controlling the signing key.

3) User/friends APIs: expose contentPublicKey (not the signing key) for sharing

  • Update:
    • GET /v1/user/:id
    • GET /v1/friends
    • GET /v1/user/search
  • Return a field like:
    • contentPublicKey: string | null (base64)
  • Do not reuse publicKey for this unless you explicitly rename it and keep backward compatibility.

4) Sharing API: do not decode Account.publicKey and do not server-encrypt keys

This aligns with the E2E model and avoids server needing recipient keys at all:

  • Change POST /v1/sessions/:sessionId/shares to accept:
    • encryptedDataKey: string (base64 of 0x00 || boxBundle)
  • The owner client:
    1. fetches recipient contentPublicKey (base64),
    2. encrypts the plaintext session DEK to that key (box bundle),
    3. sends encryptedDataKey to server.

5) Client/UI: treat contentPublicKey as optional until populated

  • Update client types: don’t make publicKey required for friends.
  • Disable “share with this user” when contentPublicKey is missing and show a clear message (e.g. “Recipient hasn’t registered encryption keys yet”).

1.3 Share access control is not applied to core session/message endpoints (feature incomplete)

You added strong helpers (checkSessionAccess, canSendMessages, etc.):

  • https://github.com/slopus/happy-server/pull/25/files#diff-5b4faba71885dd286e956cba22b375534f4910afdb1b751244367999588356b7

…but sessionRoutes.ts still only returns sessions where accountId === userId:

  • https://github.com/slopus/happy-server/blob/446dcad4820182688ce1ac8533d4313d8f48a61f/sources/app/api/routes/sessionRoutes.ts#L13-L22

So a recipient can list shared sessions via /v1/shares/sessions, but then likely cannot read messages / interact via the normal session endpoints unless those endpoints are updated to authorize via checkSessionAccess().

Requested change

  • Update session/message routes (and any socket handlers that gate access) to use checkSessionAccess():
    • view: allow read
    • edit/admin: allow send
    • admin/owner: allow manage sharing
  • This is required for “recipient can actually open & use the shared session”.

1.4 Public share token stored in plaintext (security hardening request)

Schema stores:

  • PublicSessionShare.token: String @unique and indexed
    • https://github.com/slopus/happy-server/pull/25/files#diff-5b443964f4f3a611682db8f7e02177b0a8c632b2039e2bd5e4dd7347815c565c

This is convenient, but the token is a bearer secret (and also the client uses it as the input for key derivation on the UI side), so a DB leak would leak tokens.

Requested change (recommended)

  • Store tokenHash = sha256(token) and lookup by hash.
  • Never store the raw token.

(If you want to land MVP first, this can be a follow-up, but we should decide intentionally.)


2) Merge blockers — happy#356

2.1 Public-link DEK encryption is broken (typed-array + JSON secretbox)

In publicShareEncryption.ts, the DEK (Uint8Array) is encrypted via encryptSecretBox(dataEncryptionKey, ...):

  • https://github.com/slopus/happy/pull/356/files#diff-49b20a7863c2123c18cb8cc5cc34ab6173e6e63d2f80e2fd3d175aed51d33f43

But encryptSecretBox() JSON-stringifies its input before encrypting:

  • https://github.com/slopus/happy/blob/a7a2a6fff75f35d5184eee416f2cd96011ed5cf0/sources/encryption/libsodium.ts#L36-L58

JSON.stringify(Uint8Array) does not roundtrip to bytes; it stringifies to an object like {"0":1,"1":2,...}. After decrypt, new Uint8Array(obj) becomes empty. That means public share link key decryption cannot work reliably.

Requested change

  • Add a byte-safe secretbox helper (no JSON stringify), OR base64 the DEK before secretbox, OR reuse an existing byte-safe primitive.
  • Add a small unit test proving encryptDataKeyForPublicShare roundtrips a random 32-byte key.

Concrete change requested (UI): SecretBox must not JSON-stringify a Uint8Array

In happy PR diff:

  • sources/sync/publicShareEncryption.ts (https://github.com/slopus/happy/pull/356/files#diff-49b20a7863c2123c18cb8cc5cc34ab6173e6e63d2f80e2fd3d175aed51d33f43)
  • SecretBox implementation uses JSON.stringify(data) (https://github.com/slopus/happy/blob/a7a2a6fff75f35d5184eee416f2cd96011ed5cf0/sources/encryption/libsodium.ts#L36-L58)

Minimal safe approach: encrypt a JSON payload that contains the DEK as base64 (string), not as raw Uint8Array.

import { encodeBase64, decodeBase64 } from '@/encryption/base64';

export async function encryptDataKeyForPublicShare(
  dataEncryptionKey: Uint8Array,
  token: string,
): Promise<string> {
  const tokenBytes = new TextEncoder().encode(token);
  const encryptionKey = await deriveKey(tokenBytes, 'Happy Public Share', ['v1']);

  const payload = {
    v: 0,
    keyB64: encodeBase64(dataEncryptionKey, 'base64'),
  };

  const encrypted = encryptSecretBox(payload, encryptionKey);
  return encodeBase64(encrypted, 'base64');
}

export async function decryptDataKeyFromPublicShare(
  encryptedDataKey: string,
  token: string,
): Promise<Uint8Array | null> {
  const tokenBytes = new TextEncoder().encode(token);
  const decryptionKey = await deriveKey(tokenBytes, 'Happy Public Share', ['v1']);

  const encrypted = decodeBase64(encryptedDataKey, 'base64');
  const payload = decryptSecretBox(encrypted, decryptionKey) as { v: number; keyB64: string } | null;

  if (!payload || payload.v !== 0) return null;
  return decodeBase64(payload.keyB64, 'base64');
}

2.2 Owner cannot manage sharing due to canManage logic

In the sharing screen:

  • const canManage = session.owner === currentUserId;
    • https://github.com/slopus/happy/pull/356/files#diff-769dceb005b308a5216186c58b7e80a80277fff50fe0d7830d33d0d16cda9086 (see around where canManage is computed)

But for owned sessions, session.owner is typically unset; it’s used for “shared sessions” metadata. So owners will often see canManage === false even though they should be allowed to share.

Requested change

  • Treat “no session.accessLevel” as “owner/full access” for UI gating (consistent with info.tsx which uses !session.accessLevel || session.accessLevel === 'admin').

2.3 Public share access screen always sends Authorization header (breaks anonymous access)

In /share/[token].tsx, the fetch always includes:

  • Authorization: Bearer ${credentials.token}
    • https://github.com/slopus/happy/pull/356/files#diff-e1bf187eee2122994a90719055938525551b30ab48abfd4c696403360369695a

If the user opens a public link while logged out (or before credentials are available), this will fail, even though the server PR implements /v1/public-share/:token as “no auth required”.

Requested change

  • Only add Authorization header when credentials exist, otherwise omit it.
  • Align to the API client helper you already wrote in apiSharing.ts (which explicitly supports optional credentials):
    • https://github.com/slopus/happy/pull/356/files#diff-820a1ecef55bcadb0dc94cfbbf05ac877c153bcbf8e848cb26df9ee43abe50d6

2.4 Public share flow doesn’t materialize session + encryption in storage

After decrypting the public-share DEK, the screen only stores it in a Map and navigates to /session/:id:

  • https://github.com/slopus/happy/pull/356/files#diff-e1bf187eee2122994a90719055938525551b30ab48abfd4c696403360369695a

But there’s no code that inserts the public-shared session into storage or initializes session encryption for it.

Requested change

  • Either:
    • A) treat public-share access as a special “viewer screen” (render directly from the public-share response without navigating to /session/:id), or
    • B) add a real “import public share session into storage” flow: apply session record to storage + initialize Encryption.initializeSessions() for that session ID before navigation.

2.5 API contracts are inconsistent / rely on as any casts (will be brittle)

In sync.ts, the shared sessions response is cast to include sharedBy: { id, username, name }:

  • https://github.com/slopus/happy/pull/356/files#diff-896646499fa135fcaa3ed26a1f3cf7646a6edc601e0f9eaa3379702e248a32fd

…but server PR returns a profile object with firstName/lastName/avatar/username (and no name field), and the list endpoint in server PR does not include agentState though the client expects it for decryption.

Requested change

  • Define the exact response schemas once (Zod on the client is already used elsewhere) and make both sides match.
  • Prefer using the dedicated API client module (apiSharing.ts) from sync.ts to reduce duplication and keep contracts consistent.

2.6 publicKey added as required on the client will break until server ships it

Client schema:

  • publicKey: z.string() required in friendTypes.ts
    • https://github.com/slopus/happy/pull/356/files#diff-e0a65e7c6b879439d7283a74b959a416cbad6daa80e214cb21ea170480567a1f

Requested change

  • Make publicKey optional on the client until server ships it reliably.
  • When missing, disable sharing UI for that user with a clear message (e.g., “User hasn’t registered keys yet / upgrade required”).

2.7 Minor UX correctness: PublicLinkDialog creation state doesn’t update

isCreating is initialized from !publicShare but never updated when publicShare becomes available after creation:

  • https://github.com/slopus/happy/pull/356/files#diff-ea6cd7dbc8032936f151d8f092166ec5fc61208a0d881b087a5b3f6e7fb6322f

Requested change

  • Derive “creating vs showing link” from publicShare rather than a one-time useState(!publicShare).

2.8 Repo hygiene

There are commits that add/remove .idea artifacts and then add .idea to .gitignore:

  • https://github.com/slopus/happy/pull/356/files#diff-bc37d034bad564583790a46f19d807abfe519c5671395fd494d8cce506c42947

Requested change

  • Please drop IDE artifacts from PR history (or at least avoid committing them); keep .gitignore change only if needed.

3) Proposed target design (simple, E2E-consistent, minimal new logic)

Direct sharing (friend → friend)

Client (owner)

  1. Fetch recipient contentPublicKey from /v1/friends or /v1/user/:id (wire encoding should be base64).
  2. Decrypt owner’s session DEK (it’s already decrypted & cached by the sync layer).
  3. Encrypt DEK to recipient contentPublicKey using the same NaCl box-bundle format we already use for key wrapping (encryptedDataKey = 0x00 || encryptBox(DEK, recipientPub), base64).
  4. Call POST /v1/sessions/:sessionId/shares with:
    • userId
    • accessLevel
    • encryptedDataKey (base64)

Server

  • Verify: owner/admin, friend relationship, etc.
  • Store encryptedDataKey as bytes; do not attempt to decrypt/re-encrypt keys.
  • Enforce access levels across session/message APIs + realtime.

Recipient

  • Fetch shared session list
  • Decrypt encryptedDataKey locally to recover session DEK (existing decryptEncryptionKey() flow)
  • Initialize session encryption and decrypt metadata/agentState/messages as normal.

Public share link

  • Server stores/returns encryptedDataKey as an opaque blob.
  • Client derives a symmetric key from token and encrypts the DEK (byte-safe).
  • Recommended: store tokenHash server-side (hardening).

4) Concrete checklist to update these PRs

happy-server#25

  • Remove server-side DEK encryption in POST /v1/sessions/:id/shares; accept encryptedDataKey from client instead.
  • Introduce + publish contentPublicKey (box/X25519) and keep Account.publicKey as signing (Ed25519); ensure consistent encodings at API boundaries.
  • Apply checkSessionAccess() to session/message routes + socket handlers so shared users can actually open/use sessions.
  • (Recommended) Store tokenHash instead of plaintext token for public shares.

happy#356

  • Fix public link DEK encryption to be byte-safe (no JSON secretbox on Uint8Array) + add a unit test.
  • Fix canManage logic in sharing UI so owners can manage sharing.
  • Make Authorization header optional for public share access screen.
  • Decide on public-share UX: viewer screen vs “import into storage”, and implement the missing storage/encryption initialization.
  • Align response schemas (avoid as Array<{...}> with incorrect shapes).
  • Make publicKey optional until backend ships it; disable sharing actions when missing.
  • Fix PublicLinkDialog state so it shows the created link without reopening.
  • Clean up .idea artifacts from the PR.

Important: Happy (Expo) + CLI + Server is a monorepo now (port/rebase required)

That means both your PRs needs to be ported into the new monorepo structure.

Recommended helper: Happy Stacks Monorepo Port

If you want a mostly-mechanical port that preserves commit authors/messages, happy-stacks has an experimental port helper:

npx --yes happy-stacks@latest monorepo port guide \
  --target="$PWD/slopus-happy-monorepo-ported" \
  --branch="session-sharing-monorepo" \
  --3way \
  --from-happy="https://github.com/slopus/happy/pull/356" \
  --from-happy-server="https://github.com/slopus/happy-server/pull/25"

Then follow the prompts (it will preflight first; if conflicts are predicted, it’ll offer LLM vs guided/manual conflicts resolution).

It will guide you through the whole port and offer to launch LLMs to resolve the conflicts. The final ported repo will be in $PWD/slopus-happy-monorepo-ported (configure it in the command above if you want another target).

Option B: instead of PRs URLs, use local checkouts

npx --yes happy-stacks@latest monorepo port guide \
  --target="$PWD/slopus-happy-monorepo-ported" \
  --branch="session-sharing-monorepo" \
  --3way \
  --from-happy="/abs/path/to/your/local/happy-repo-or-worktree" \
  --from-happy-server="/abs/path/to/your/local/happy-server-repo-or-worktree"

Hope that helps!

@leeroybrun
Copy link
Collaborator

Just to let you know, @54m : we are planning a big new release. I can integrate your commits into it and apply the few things I have noted on top of them, if that's alright with you?

@leeroybrun leeroybrun mentioned this pull request Jan 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants