67 Commits

Author SHA1 Message Date
maaz519 c9cd0c847b Merge pull request 'Feat/messaging ui foundation' (#13) from feat/messaging-ui-foundation into dev
Reviewed-on: #13
2026-07-25 12:28:37 +00:00
maaz519 d2820a64e3 chore(kernel-client): 0.1.6
0.1.5 was published with npm, which does not rewrite pnpm's workspace: protocol,
so its package.json carried '@insignia/iios-contracts: workspace:*' and npm
consumers failed with EUNSUPPORTEDPROTOCOL. Republished via pnpm publish. Do not
use 0.1.5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 17:57:23 +05:30
maaz519 272c6acd31 fix(messaging): a sent message no longer shows "Seen" instantly
Two independent defects; either alone caused it, in DMs, groups and channels.

1. useMessages reported a read of the newest NON-PENDING message regardless of
   author. Sending therefore made the client immediately mark its own message
   read, the server echoed a receipt for it, and the sender's bubble showed
   "Seen" before anyone had opened the thread. You do not read your own message:
   lastReadableId now skips your own.

2. The guard meant to catch exactly this — `e.actorId !== currentActorId` — could
   never fire: the receipt carries the reader's IIOS actor UUID while clients hold
   a userId, so the comparison was always true and every receipt, including your
   own, counted as the other side. The gateway now also emits userId on READ and
   DELIVERED receipts (matching what `annotation` already did), and ReceiptEvent
   carries it as optional so older servers still typecheck.

MockAdapter.markRead was a no-op, so it could not exercise any of this; it now
echoes a receipt like a real server. Regression test verified to fail without
the fix. SDK 0.1.14; suite 17 files / 101 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 17:57:23 +05:30
maaz519 eb0ace8ad7 fix(messaging-ui): composer height was being clobbered by a class-name collision
The chat composer reused .miu-textarea, which the INBOX MAIL composer already
owned further down the stylesheet with `resize: vertical; min-height: 90px`.
Equal specificity, later rule wins — so the mail styling applied to the chat box:
90px tall with a resize grabber, and every height fix in 0.1.10–0.1.12 was
silently overridden. That is why the box never changed.

The composer now uses its own .miu-composer-box; the inbox rule is untouched.
Adds a regression test asserting the composer does not carry .miu-textarea.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 17:57:23 +05:30
maaz519 e2f66fe622 fix(messaging-ui): composer input back to its original 38px height
0.1.11 left it at 40px with line-height 1.45, so the line (20.3px) overflowed
the 20px content box — taller than the <input> it replaced, and liable to show a
scrollbar on a single line.

Collapsed height is now one token, --miu-composer-h: 38px, shared by the
textarea, attach and send so they cannot drift apart again. Padding tightened to
8px (from .miu-input's 9px) with line-height 1.4, so a 14px line is 19.6 + 16 + 2
= 37.6px and fits exactly — matching the original single-line input.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 17:57:23 +05:30
maaz519 9a0c74cb6e fix(messaging-ui): composer no longer stretches the send/attach buttons
The textarea swap made the composer row tall and dragged the controls with it.
Three causes, all fixed:

- No box-sizing anywhere in the stylesheet, so `min-height: 38px` on a padded,
  bordered textarea rendered ~58px (content-box adds 18px padding + 2px border
  on top). The textarea is now border-box with a 40px min-height — the same
  height the old single-line input had.
- .miu-composer-row is display:flex with no align-items, so it defaulted to
  `stretch` and the buttons — neither of which declared a height — grew to the
  row. Now align-items: flex-end, so controls stay pinned to the bottom while
  the box grows upward (WhatsApp behaviour), with an explicit 40px on both.
  The send height is scoped to .miu-composer so modal/settings buttons keep
  their own sizing.
- The auto-grow effect set height = scrollHeight, which under content-box
  double-counted padding on every keystroke. It now compensates for the border
  explicitly, correct under border-box.

Bumped to 0.1.11. SDK suite: 17 files / 98 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 17:57:23 +05:30
maaz519 0c8eaaf74b fix(messaging-ui): unreadable code chip on own messages + raw markers in previews
Two bugs from the formatting work, both visible in the CRM messenger:

1. Inline code / code blocks were invisible in your OWN bubbles. `.miu-code` sets
   background: --miu-panel-2 (#1d1d26) while the is-mine override set only the
   colour to --miu-accent-text (#1a1206) — near-black on near-black, ~1.03:1
   contrast. The chip now tints the accent bubble (rgba(0,0,0,.16)) instead of
   using the panel colour, so it reads against any accent.

2. The conversation list showed the raw last message, so a strikethrough message
   previewed as "~crazy~". Adds stripMarkup() — markers off, code unwrapped,
   nesting handled, honouring the same word-boundary rule so snake_case_name and
   "5 * 3" survive — and uses it for the preview line.

Bumped to 0.1.10. SDK suite: 17 files / 98 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 17:57:23 +05:30
maaz519 ca02da000f Merge pull request 'Feat/messaging ui foundation' (#12) from feat/messaging-ui-foundation into dev
Reviewed-on: #12
2026-07-25 11:14:00 +00:00
maaz519 2c61f49de1 docs(env): document REDIS_URL — it now also gates cross-replica presence
REDIS_URL was read by the code but absent from .env.example. Beyond the socket.io
fan-out it now selects RedisPresenceService, so leaving it unset in a multi-replica
deploy silently degrades the notification presence gate to per-process.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:43:03 +05:30
maaz519 c946f1e061 feat(messaging-ui): message formatting + native spellcheck in the composer
Composer becomes a <textarea> so the PLATFORM supplies text services: red
spellcheck squiggles, right-click suggestions / add-to-dictionary, and mobile
autocorrect (spellCheck + autoCorrect + autoCapitalize). Nothing shipped for it.
Previously a single-line <input>, which Firefox does not spellcheck by default
(layout.spellcheckDefault=1 checks multi-line only) and which could not hold a
multi-line message at all. Enter sends, Shift+Enter (and IME composition) makes a
newline, and the box grows with content.

WhatsApp-style markup: *bold*, _italic_, ~strike~, `code`, ```fenced blocks```,
plus bare URLs. Cmd/Ctrl+B/I/E and a small toolbar wrap the selection in markers.
Messages stay PLAIN TEXT on the wire, so stored history and older clients are
unaffected — formatting is purely a render concern.

renderRichText() returns a ReactNode tree and never uses dangerouslySetInnerHTML,
so message text cannot inject markup; links are restricted to http/https/mailto
(safeHref) to close the javascript: vector. Code is tokenized first and its
contents stay literal; markers require word boundaries so snake_case_name and
"5 * 3" are not mangled.

Bumped to 0.1.9. SDK suite: 17 files / 95 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:43:03 +05:30
maaz519 3d08fa42f8 feat(messaging-ui): import a channel's roster from the settings panel
Phase C of channel-roster import. Adds the optional addMembers(threadId, userIds)
adapter seam (+ BulkAddResult) and, in ConversationSettings, a '#' mode on the
existing Add-people box that lists the channels you belong to — public AND
private, since the source list is your own membership-scoped conversation list.

Picking a channel STAGES the import (reads its roster, diffs against who is
already here) and shows a confirm — 'Add 9 people from #design? (3 already here)'
— so an administrative action never fires on a stray click. The result line
reports added / already-a-member / failed. Absent addMembers => the affordance is
hidden entirely and '#' is just a search string.

Bumped to 0.1.8. SDK suite: 15 files / 78 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:43:03 +05:30
maaz519 0336621c01 feat(threads): govern roster reads + add a bulk participant primitive
Phase A of channel-roster import (add everyone from another channel).

SECURITY: listParticipants was gated by iios.thread.read, which has no case in
DevOpaPort and so fell through to default-allow — any caller in a scope could
enumerate ANY thread's members, including private channels they aren't in. Since
PlatformModule binds LocalDevPorts unconditionally (no real OPA adapter exists),
that was live. Adds iios.thread.participant.list: members only, public channels
exempt, ungoverned threads unchanged. This is also Zoom's rule for this feature
('you must be a member of the channel to invite all of its members').

Adds MessageService.addParticipants(): many users in ONE governed call, capped at
MAX_BULK_PARTICIPANTS (200), idempotent (already-members are 'skipped'), and
deliberately non-atomic so one unresolvable user can't sink an import — outcome
is reported per user as {added, skipped, failed}. The dm two-person cap now reads
targetCount so it holds for a batch, not just one add at a time. New REST route
POST /v1/threads/:id/participants/bulk.

The kernel stays generic: it takes an explicit userId list and never learns where
that list came from — chat meaning lives only in the policy plane.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:43:03 +05:30
maaz519 4f9a252118 Merge pull request 'Feat/messaging ui foundation' (#11) from feat/messaging-ui-foundation into dev
Reviewed-on: #11
2026-07-23 10:32:03 +00:00
maaz519 1cbfddd4c2 feat(presence): Redis-backed presence for multi-replica prod
Extract a PresencePort seam (async setFocus/clearSocket/isViewing) with two
impls: the existing in-memory Map (dev, single instance) and a new
RedisPresenceService (prod). MessageModule provides PRESENCE_PORT via a
REDIS_URL-gated factory — same pattern as the socket.io Redis adapter — so
'who is viewing what' is shared across replicas and the notification projector's
presence gate works at N>1. Gateway + projector inject the port; isViewing is
now awaited. Presence spec updated to async (passing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 16:00:49 +05:30
maaz519 90e37edf89 feat(notifications): push for inbound mail, not just messenger
NotificationProjector now also consumes interaction.normalized and, for kind
EMAIL (external email + app-to-app mail, which land via ingest — not
message.sent), always notifies the non-sender recipient(s), reusing the same
presence + mute gates. Refactors the fan-out into a shared notify() with an
alwaysNotify flag (DM or mail); message.sent keeps its DM/mention/reply policy.
Adds specs: mail notifies in a group thread where a plain message would not, and
a non-EMAIL normalized interaction does not notify.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 16:00:49 +05:30
maaz519 b8bf1aa347 chore(env): document VAPID_* for Web Push offline notifications
Web Push (VAPID) env for the already-built notification stack: unset
VAPID_PUBLIC_KEY disables sends (WebPushDelivery returns 'failed'). Generate
with 'npx web-push generate-vapid-keys'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 16:00:49 +05:30
maaz519 745a23823a feat(messaging-ui): presence + live-unread adapter seams
Add optional MessagingAdapter.setFocus(threadId) and subscribeActivity(cb)
so hosts can report the foregrounded thread (backend suppresses push for it)
and drive live conversation-list refresh on any thread's activity. Messenger
wires focus/blur presence + activity-driven refetch. Published as 0.1.7.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 16:00:49 +05:30
maaz519 d1d4b56201 Merge pull request 'Feat/messaging ui foundation' (#10) from feat/messaging-ui-foundation into dev
Reviewed-on: #10
2026-07-23 09:23:30 +00:00
maaz519 b3eb027071 feat(messaging-ui): focusThreadId on Messenger + Inbox for search deep-links (0.1.6)
A host can pass focusThreadId to select/open a specific conversation — used by the
CRM's global search to jump to the exact thread. Mail switches to Open + selects the
matching item; messenger selects the thread. (Source for the already-published 0.1.6.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 14:52:15 +05:30
maaz519 f5c89159f4 chore(search): wire Meilisearch — local compose + prod k8s manifests
Add a meilisearch service to the dev docker-compose (port 7700, persistent volume)
and MEILI_URL/MEILI_KEY (+ IIOS_CRED_KEY, IIOS_MEDIA_GC_INTERVAL_MS) to .env.example.
Add deploy/meilisearch.yaml — a ready-to-apply k8s Deployment/Service/PVC/Secret for
the ArgoCD prod stack (copy into k8s-pods/services/iios/), with wiring notes for the
iios-service env (MEILI_URL=http://meilisearch:7700, MEILI_KEY from the secret).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 14:52:15 +05:30
maaz519 8af25def83 feat(search): Meilisearch message search — indexer + permission-scoped query
New search module: a message index (Meilisearch, MEILI_URL-gated; no-op when unset),
a SearchProjector that indexes on message.sent, and a SearchService whose permission
fence runs server-side — a query only touches the caller's own scope AND the threads
they currently belong to (resolved live from participant rows, so membership changes
reflect immediately). Every conversation kind (chat/mail/sms) is a message with a TEXT
part, so all are searchable through one index. POST /v1/search + /reindex (backfill).
5 fence tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 14:52:15 +05:30
maaz519 23c43acf8f feat(capability): BYO SMTP — per-tenant email server via the credential registry
SmtpProvider is now scope-aware: it resolves the tenant's own SMTP identity from
the IiosProviderCredential store (providerType SMTP) and sends from it, falling
back to the platform env identity when unset (env fallback applies only to the
platform identity, never a tenant's server). Registered whenever env SMTP OR the
credential store is present; fails closed as NOT_CONFIGURED when neither exists.
Credential endpoints accept SMTP with per-type validation. 18 SMTP tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 14:52:15 +05:30
maaz519 e503ed8904 Merge pull request 'feat(messaging-ui): message timestamps + group/channel settings panel (0.1.5)' (#9) from feat/messaging-ui-foundation into dev
Reviewed-on: #9
2026-07-23 07:33:56 +00:00
maaz519 416cf59dc2 feat(messaging-ui): message timestamps + group/channel settings panel (0.1.5)
- Every message now shows a Slack-style time (clock today, then 'Yesterday',
  weekday, else a date; full timestamp on hover).
- New ConversationSettings panel for groups AND channels (public + private):
  rename, member list, add people (from the directory), remove, and leave.
  Opened from a new thread header gear; DMs show no gear. Adapter gains
  addMember/removeMember/renameConversation (optional, OPA still gates writes).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 13:03:06 +05:30
maaz519 aa73dee05d Merge pull request 'Feat/messaging ui foundation' (#8) from feat/messaging-ui-foundation into dev
Reviewed-on: #8
2026-07-22 21:42:56 +00:00
maaz519 973b6a77eb feat(messaging-ui): Attachment carries contentRef + sizeBytes for messenger media (0.1.4)
Extend the messaging Attachment type with optional storage fields so an adapter's
upload() can return a sendable reference (the socket needs contentRef/mimeType/
sizeBytes to persist the message part) while keeping the display url. Enables
messenger attachments end-to-end in host adapters.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 03:10:31 +05:30
maaz519 3c5c6964e2 fix(messaging-ui): mail cards no longer clip + emoji picker portals above overflow (0.1.3)
- Mail reader: .miu-mail-msg gets flex:0 0 auto so cards keep their natural height
  in the scrolling column instead of being compressed + clipped by overflow:hidden.
- Reaction/emoji picker renders through a PopoverPortal to <body> (positioned +
  themed) so it floats above the message list instead of being cut by the scroll
  container's overflow. 72 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 03:10:31 +05:30
maaz519 2753a8a367 Merge pull request 'Feat/messaging ui foundation' (#7) from feat/messaging-ui-foundation into dev
Reviewed-on: #7
2026-07-22 20:43:35 +00:00
maaz519 af45982176 Merge branch 'dev' into feat/messaging-ui-foundation 2026-07-23 02:13:10 +05:30
maaz519 0ee63c139f feat(media): orphan cleanup — track objects + reap unreferenced attachments
Attachments upload direct-to-storage on attach, so an abandoned attach (removed,
cancelled, tab closed) would linger forever. Add IiosMediaObject: a row is recorded
when bytes land (MediaService.put); a scheduled sweepOrphans() reaps objects past a
grace window (IIOS_MEDIA_ORPHAN_GRACE_MS, default 24h) that no IiosMessagePart
references (derived live — no flag to drift). Storage delete via the StoragePort;
best-effort + idempotent. Gated on IIOS_MEDIA_GC_INTERVAL_MS (off by default).
Migration 20260722193958_media_object_tracking. 10 media tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 01:19:11 +05:30
maaz519 22eaf0f654 feat(messaging-ui): Gmail-style uploading chip while an attachment uploads (0.1.2)
Picking a file now shows an immediate chip with the filename + a spinner while
the bytes upload (mail compose, mail reply, and the messenger composer), instead
of only appearing once upload finishes. Respects prefers-reduced-motion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 01:04:52 +05:30
maaz519 8878ee8c54 feat(messaging-ui): mail attachment download + scrollable reader (0.1.1)
- Attachment chips in the mail reader are now clickable: new InboxAdapter
  downloadAttachment() resolves a short-lived URL, opened in a new tab.
- Mail reader scrolls again: convert the .miu-detail/.miu-mail height:100%
  chain to flex fill so a long thread scrolls within the pane instead of
  being clipped. MockInboxAdapter implements download. Bump to 0.1.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:53:08 +05:30
maaz519 ba264f401d Merge pull request 'Feat/messaging ui foundation' (#6) from feat/messaging-ui-foundation into dev
Reviewed-on: #6
2026-07-22 18:46:31 +00:00
maaz519 c4254749a4 fix(messaging-ui): stop Start-chat/Create buttons stretching full width
The new-conversation + channel-create forms are flex columns, so the submit
button stretched to full width and read as thin. Give it align-self:flex-start
+ a 38px height so it sizes to its label like a normal button.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:21 +05:30
maaz519 4faf05fee9 feat(messaging-ui): start direct messages + groups (Slack-style people picker)
Add a 'New message' + on the Direct messages section that opens a people picker
(NewConversation): pick one person -> DM, two or more -> group with an optional
name, via the adapter's existing openThread({participantIds, membership, subject}).
Expose directory() on MessagingAdapter (org people list); MockAdapter implements
it + dedupes 1:1 DMs. 72 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:21 +05:30
maaz519 9882177c59 fix(messaging-ui): portal compose modal to <body> so it renders above host chrome
A host wrapper like .view{position:relative;z-index:1} creates a stacking context
that traps the modal's fixed overlay below a sibling sticky header, no matter its
z-index. Render the modal through a ModalPortal (createPortal to document.body) so
it escapes the host stacking context + overflow entirely; the portal root copies
the SDK theme tokens from the live surface (synchronously, no unstyled paint) and
carries dark defaults as a fallback. Adds react-dom peer dep. 67 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:21 +05:30
maaz519 f16d7986c7 fix(migration): regenerate provider_credentials via prisma engine, not hand-written SQL
Replace the hand-authored migration with one produced by `prisma migrate diff`
(offline, engine-generated) and verified by replaying the full 24-migration chain
into a throwaway Postgres DB. Same DDL, but generated through Prisma tooling so the
migration history stays consistent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:21 +05:30
maaz519 ddd628ab6f feat(capability): per-scope BYO provider credentials + Twilio SMS egress
IIOS becomes the tenant's integration/credential hub for anything it sends.
New IiosProviderCredential (one row per scope+providerType) seals the secret
with AES-256-GCM under IIOS_CRED_KEY (secret-crypto.ts); only non-secret
displayHints are ever read back. ProviderCredentialService upsert/resolve/status;
TwilioSmsProvider resolves the caller's own creds per scope at send time and
POSTs to Twilio (fail-closed NOT_CONFIGURED when unset). Registered over the
SMS sandbox only when the platform key + store are present. PUT/GET
/v1/providers/:type/credentials (scope-fenced, masked reads). 16 new unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:21 +05:30
maaz519 2aa2893973 feat(messaging-ui): attachments in inbox mail — reply + compose upload
InboxAdapter gains uploadAttachment + attachment params on mailReply/
composeInternal/composeExternal. MailReader reply and ComposeModal grow an
attach affordance (paperclip + pending chips); mail history renders sent
attachments. MockInboxAdapter implements upload. 67 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:21 +05:30
maaz519 2f24a95ef4 feat(messaging-ui): Inbox domain — unified work items + mail (contract + UI + mock)
Second domain in the SDK, mirroring messaging's adapter/provider/hooks/components:
- InboxAdapter (listInbox unified feed + transition + mailHistory/mailReply +
  optional directory/composeInternal/composeExternal), InboxProvider/useInboxAdapter
- hooks: useInbox (filter + transition), useMailThread (history + reply), useCompose
- <Inbox> (filter tabs, unified list, detail pane), <MailReader> (HTML in a
  sandboxed iframe + reply), compose modal (in-app / email); themeable styles
- MockInboxAdapter (seeded mentions/needs-reply/alert + folded mail threads +
  directory) shipped from ./adapters/mock-inbox
- 6 inbox tests (mock unify/transition/reply + render open/reply/compose); 64 green

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:21 +05:30
maaz519 d02cd152de fix(messaging-ui): reaction picker opens downward, no longer clips
The emoji picker used bottom:100% and was clipped by the message list's
overflow when reacting to a message near the top (and could spill past the
right edge). Open it downward + edge-anchored (right for others' messages,
left for my own) with width:max-content so all emojis stay visible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:21 +05:30
maaz519 fa93173b1f feat(messaging-ui): Slack-style threaded-replies side panel
Replies (messages with parentInteractionId) now open in a right-hand ThreadPane
instead of inline quoting:
- extracted a shared Composer (draft + @mention autocomplete + attach) and
  MessageItem (bubble + mentions + attachment + reactions + reply affordance)
- Thread shows top-level messages only; a message with replies gets a
  '💬 N replies' link, and hovering shows 💬 'Reply in thread'
- ThreadPane renders the root + its replies + a composer that posts back with
  parentInteractionId; Messenger becomes 3-column (list | thread | pane),
  pane closes on conversation switch
- no contract/adapter/backend change (parentInteractionId was already wired)
- thread-pane render test (open → reply → parent shows the count); 58 green

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:21 +05:30
maaz519 7a0fb2ca97 feat(messaging-ui): reaction picker + attachments in the Thread
- useMessages exposes upload(); Thread gains a hover reaction picker (react to
  a message) and an attach button (upload → stage → send), plus inline image /
  file rendering of message attachments — all capability-degrading (hidden when
  the adapter lacks react/upload)
- themeable styles for the picker, attach button, staged chip, and attachments
- 57 tests still green

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:21 +05:30
maaz519 ade1d68015 feat: channels on the direct-IIOS path — kernel-client + KernelClientAdapter
- iios-kernel-client: RestClient.discoverThreads (GET /v1/threads/discover) +
  leaveThread (DELETE /v1/threads/:id/me); DiscoveredThread type
- KernelClientAdapter implements the four channel methods: browseChannels maps
  discovered public channels → ChannelSummary; createChannel → createThread
  (membership=channel, ADMIN, visibility/topic metadata); joinChannel →
  socket.openThread (governed public self-join); leaveChannel → rest.leaveThread
- adapter channel test over the fake transport (13 kernel-adapter tests); 57 green

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:21 +05:30
maaz519 ebf553eb68 feat(threads): channel backend — discover (browse), public self-join, leave
The generic primitives channels need beyond dm/group, kept policy-governed and
scope-fenced (kernel never interprets 'channel'/'public'):

- discoverThreads(principal, filter): scope-wide browse (NOT membership-scoped)
  matching an opaque metadata filter, each result flagged joined; governed by
  iios.thread.discover (scope fence in the query). REST: GET /v1/threads/discover
- open self-join for PUBLIC channels: openThread now passes visibility into the
  join decision; dev-OPA iios.thread.join allows membership=channel+visibility=
  public (dm/group/private-channel stay invite-only)
- leaveThread + iios.thread.leave + REST DELETE /v1/threads/:id/me
- tests: public self-join vs private denied, discover joined-flags + group
  excluded + leave; dev-opa join-public/discover/leave rules (29 green)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:21 +05:30
maaz519 cb9a0b9250 feat(messaging-ui): @mentions — autocomplete, resolve to ids, highlight
- contract: SendOpts.mentions (opaque userId notify-list) + optional
  listMembers(threadId) for autocomplete/highlight (capability-degrading)
- mock: listMembers; KernelClientAdapter.send forwards mentions to the socket
  (which already carries them → inbox MENTION items)
- composer: type @ → member/@channel/@here autocomplete; Enter picks the first;
  on send, mentions resolve to ids; message bodies highlight @mentions
- useMembers hook; mentions.tsx helpers (trailingMentionQuery/insertMention/
  resolveMentions/highlightMentions)
- 4 mention tests (helpers + render autocomplete→send carries id); 56 total green

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:21 +05:30
maaz519 00a2cc474a feat(messaging-ui): channels — browse, join, leave, create (contract + mock + UI)
Adds a third membership beyond dm/group: a discoverable, joinable room.

- contract: Membership += 'channel'; Conversation.topic; ChannelSummary +
  CreateChannelInput; optional adapter methods browseChannels/createChannel/
  joinChannel/leaveChannel (capability-degrading — absent hides the UI)
- MockAdapter implements them (seeds a joined public, a joinable public, and a
  private channel); listConversations now returns only threads I'm in
- useChannels hook (browse/create/join/leave + supported flag)
- UI: sidebar sections (Channels vs Direct messages), a + that opens a
  ChannelBrowser (join + create), # / lock glyphs; themeable styles
- KernelClientAdapter passes channel membership + topic through
- 4 channel tests (mock browse/join/create + render browse→join); 52 total green

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:20 +05:30
maaz519 3f1f89dfbe feat(messaging-ui): rendered Messenger components — the drop-in UI
Turns the SDK from hooks-only into a real UI SDK: <Messenger> (conversation
list + open thread + composer), plus <ConversationList> and <Thread>, all
driven by the existing useConversations/useMessages hooks over the injected
adapter — zero transport imports (boundary intact).

- themeable via --miu-* CSS variables; default theme ships as ./styles.css
- optimistic send, typing indicator, seen ticks, reactions display, unread badges
- render smoke tests (jsdom + MockAdapter): mounts, auto-selects first thread,
  sends a message, switches threads (3 tests) — 48 total green
- tsup: css bundled to dist/styles.css; dts scoped to TS entries

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:20 +05:30
maaz519 c5237a237a feat(messaging-ui): KernelClientAdapter — direct-IIOS transport for the SDK
Implements MessagingAdapter over @insignia/iios-kernel-client (browser → IIOS,
token-in): listConversations/openThread/history/send/subscribe/typing/markRead
/react, mapping kernel Message/ThreadSummary/annotations onto the SDK's neutral
types. currentActorId comes from the host's session (senderId space), never
inferred from history — the exact bug the conformance suite kills.

- lives in adapters/ (transport boundary intact — core stays transport-free)
- ships from the ./adapters/kernel-client subpath (kernel-client is an optional
  peer dep; excluded from the main bundle)
- connectKernelAdapter({serviceUrl, token, currentUserId}) convenience
- passes the shared adapter conformance suite (12 tests) over the real
  MessageSocket facade with only the socket.io layer faked
- media/attachments deferred (kernel-client has no presign yet)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:20 +05:30
maaz519 191c9748c5 docs: messaging-ui foundation implementation plan (plan 1 of 3)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 00:15:20 +05:30
maaz519 778e98134c feat: export messaging-ui public API and enforce transport boundary
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 00:15:20 +05:30
maaz519 99f9ac84fb feat: add useMessages hook with explicit ownership and optimistic send
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 00:15:20 +05:30
maaz519 0ffe21a0c2 feat: add useConversations hook 2026-07-23 00:15:20 +05:30
maaz519 256a5dcea0 feat: add MessagingProvider and useAdapter 2026-07-23 00:15:20 +05:30
maaz519 54f426e4f0 feat: add MockAdapter passing the conformance suite
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 00:15:20 +05:30
maaz519 0273d1c70f feat: add adapter conformance suite 2026-07-23 00:15:20 +05:30
maaz519 3e9951b3a8 feat: define MessagingAdapter contract 2026-07-23 00:15:20 +05:30
maaz519 0c9b27684b feat: add messaging-ui domain types 2026-07-23 00:15:20 +05:30
maaz519 167171a682 chore: scaffold @insignia/iios-messaging-ui with jsdom test setup
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 00:15:17 +05:30
maaz519 00d22be714 Merge pull request 'feat(media): allow HTML/Markdown/CSV uploads; serve scriptable types as downloads' (#5) from feat/s3-storage into dev
Reviewed-on: #5
2026-07-20 09:01:56 +00:00
maaz519 b4104b9769 feat(media): allow HTML/Markdown/CSV uploads; serve scriptable types as downloads
- media upload policy now allows text/html, text/markdown, text/x-markdown,
  text/csv (in addition to images/av, pdf, txt, zip, office docs)
- blob endpoint adds X-Content-Type-Options: nosniff, and forces
  Content-Disposition: attachment for script-capable types (html, xhtml, svg,
  xml) so an uploaded file can't render/execute inline from the IIOS origin
  (stored-XSS). Images/video/audio/pdf still serve inline for preview.
- dev-opa test covering the allowed types + unknown/oversize denials

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 14:30:25 +05:30
maaz519 32aa04503d Merge pull request 'Feat/s3 storage' (#4) from feat/s3-storage into dev
Reviewed-on: #4
2026-07-18 12:28:22 +00:00
maaz519 c8c2d0811b Merge remote-tracking branch 'origin/dev' into feat/s3-storage 2026-07-18 17:57:21 +05:30
maaz519 ce33834d56 feat(threads): governed group settings — rename, list members, remove participant
- MessageService.renameThread / removeParticipant / listParticipants,
  each fail-closed via OPA (kernel stays generic — no dm/group branching)
- dev OPA: iios.thread.update + iios.thread.participant.remove rules
  (group requires ADMIN; ungoverned threads unchanged)
- REST: PATCH /v1/threads/:id, GET + DELETE /v1/threads/:id/participants
- tests: admin renames/removes, member is denied, member list carries roles
  (message.spec + dev-opa.port.spec)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:34:28 +05:30
maaz519 b164ef945c feat(mail): in-app attachments on internal mail + surface media parts in thread reads
- MailInternalDto accepts attachments[]; deposit() stores each as a media
  part (image/video→MEDIA_REF, audio→VOICE_REF, else FILE_REF)
- ingest now persists part sizeBytes; contract + DTO carry it
- threads.getMessages returns mimeType + sizeBytes so mail readers can
  render inline images / file chips
- test: internal mail stores an image attachment as a MEDIA_REF part

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:24:49 +05:30
maaz519 18498ee9fa Merge pull request 'feat(mail): tag mail threads source=crm-mail (list separately from chat)' (#3) from feat/s3-storage into dev
Reviewed-on: #3
2026-07-18 11:12:35 +00:00
maaz519 50f9b3213d Merge pull request 'Feat/s3 storage' (#1) from feat/s3-storage into dev
Reviewed-on: #1
2026-07-18 09:06:39 +00:00
113 changed files with 10025 additions and 88 deletions
+27
View File
@@ -6,3 +6,30 @@ DATABASE_URL="postgresql://iios:iios@localhost:5434/iios?schema=public"
JWT_SECRET="dev-only-change-me" JWT_SECRET="dev-only-change-me"
# Per-app HS256 secrets, JSON map keyed by appId (the `session` platform port) # Per-app HS256 secrets, JSON map keyed by appId (the `session` platform port)
APP_SECRETS={"portal-demo":"dev-secret"} APP_SECRETS={"portal-demo":"dev-secret"}
# Redis. Unset → single-instance mode: socket.io uses its in-memory adapter (realtime does NOT
# fan out across replicas) and presence is a per-process Map (so the "don't push a thread you're
# viewing" gate only sees sockets on the same replica). REQUIRED for any multi-replica deploy.
REDIS_URL="redis://localhost:6379"
# Full-text message search (Meilisearch). Unset MEILI_URL → search is disabled (no-op).
# MEILI_KEY must equal the meilisearch server's MEILI_MASTER_KEY (docker-compose default below).
MEILI_URL="http://localhost:7700"
MEILI_KEY="dev-meili-master-key"
# Consumed by docker-compose's meilisearch service; keep in sync with MEILI_KEY.
MEILI_MASTER_KEY="dev-meili-master-key"
# BYO integration credentials at rest (Twilio SMS, SMTP): 32-byte base64 AES-256-GCM key.
# Generate with: openssl rand -base64 32
IIOS_CRED_KEY=""
# Orphaned-media garbage collection (uploaded-but-never-sent attachments).
# Interval 0 = off; set e.g. 3600000 (hourly) in prod. Grace defaults to 24h.
IIOS_MEDIA_GC_INTERVAL_MS=0
# Web Push (VAPID) for offline notifications. Unset VAPID_PUBLIC_KEY → push is disabled
# (subscribe endpoint still stores subs, but WebPushDelivery returns 'failed' — no sends).
# Generate a keypair with: npx web-push generate-vapid-keys
VAPID_PUBLIC_KEY=""
VAPID_PRIVATE_KEY=""
VAPID_SUBJECT="mailto:dev@insignia"
+19
View File
@@ -29,5 +29,24 @@ services:
timeout: 5s timeout: 5s
retries: 10 retries: 10
# Full-text message search. The service uses it when MEILI_URL is set (else search no-ops).
# Point the service at it with MEILI_URL=http://localhost:7700 + MEILI_KEY=<master key>.
meilisearch:
image: getmeili/meilisearch:v1.11
container_name: iios-meili
ports:
- "7700:7700"
environment:
MEILI_NO_ANALYTICS: "true"
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:-dev-meili-master-key}
volumes:
- iios_meili_data:/meili_data
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:7700/health || exit 1"]
interval: 5s
timeout: 5s
retries: 10
volumes: volumes:
iios_postgres_data: iios_postgres_data:
iios_meili_data:
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.
+1
View File
@@ -27,6 +27,7 @@ export interface IngestInteractionRequest {
bodyText?: string; bodyText?: string;
contentRef?: string; contentRef?: string;
mimeType?: string; mimeType?: string;
sizeBytes?: number;
}>; }>;
occurredAt: string; occurredAt: string;
providerEventId?: string; providerEventId?: string;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@insignia/iios-kernel-client", "name": "@insignia/iios-kernel-client",
"version": "0.1.4", "version": "0.1.6",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"module": "dist/index.js", "module": "dist/index.js",
+22 -1
View File
@@ -1,5 +1,5 @@
import type { IngestInteractionRequest } from '@insignia/iios-contracts'; import type { IngestInteractionRequest } from '@insignia/iios-contracts';
import type { Message, InboxItem, InboxState, Ticket, TicketState, CallbackRequest, RouteBinding, RouteDecision, AiArtifact, AiJobResult, Meeting, MeetingActionItem, ThreadSummary, SavedItem, LoginResult } from './types'; import type { Message, InboxItem, InboxState, Ticket, TicketState, CallbackRequest, RouteBinding, RouteDecision, AiArtifact, AiJobResult, Meeting, MeetingActionItem, ThreadSummary, DiscoveredThread, SavedItem, LoginResult } from './types';
export interface RestConfig { export interface RestConfig {
serviceUrl: string; serviceUrl: string;
@@ -103,6 +103,27 @@ export class RestClient {
return this.post<{ threadId: string }>('/v1/threads', opts); return this.post<{ threadId: string }>('/v1/threads', opts);
} }
/**
* Discover threads across your scope matching an opaque metadata filter (e.g. browse public
* channels: `{ membership: 'channel', visibility: 'public' }`). Returns ones you have NOT joined
* too, each flagged `joined`.
*/
async discoverThreads(filter?: { metadata?: Record<string, string> }): Promise<DiscoveredThread[]> {
const qs = filter?.metadata
? '?' + Object.entries(filter.metadata).map(([k, v]) => `metadata[${encodeURIComponent(k)}]=${encodeURIComponent(v)}`).join('&')
: '';
const r = await fetch(this.url(`/v1/threads/discover${qs}`), { headers: this.headers() });
if (!r.ok) throw new Error(`discoverThreads ${r.status}`);
return (await r.json()) as DiscoveredThread[];
}
/** Self-leave a thread (e.g. leave a channel). */
async leaveThread(threadId: string): Promise<{ threadId: string; participantCount: number }> {
const r = await fetch(this.url(`/v1/threads/${threadId}/me`), { method: 'DELETE', headers: this.headers() });
if (!r.ok) throw new Error(`leaveThread ${r.status}`);
return (await r.json()) as { threadId: string; participantCount: number };
}
/** My saved messages (personal bookmarks), newest first, with thread context. */ /** My saved messages (personal bookmarks), newest first, with thread context. */
async listSaved(): Promise<SavedItem[]> { async listSaved(): Promise<SavedItem[]> {
const r = await fetch(this.url('/v1/threads/my-annotations?type=save'), { headers: this.headers() }); const r = await fetch(this.url('/v1/threads/my-annotations?type=save'), { headers: this.headers() });
+13
View File
@@ -37,7 +37,11 @@ export interface OpenThreadResult {
export interface ReceiptEvent { export interface ReceiptEvent {
interactionId: string; interactionId: string;
/** The reader's IIOS actor UUID — an internal id, NOT comparable to a caller's userId. */
actorId: string; actorId: string;
/** The reader's userId — the same id space clients hold, so they can ignore their own receipt.
* Optional: absent from servers older than the change that added it. */
userId?: string;
kind: 'READ' | 'DELIVERED'; kind: 'READ' | 'DELIVERED';
} }
@@ -64,6 +68,15 @@ export interface MessageEvents {
annotation: (e: AnnotationEvent) => void; annotation: (e: AnnotationEvent) => void;
} }
/** A discoverable thread from GET /v1/threads/discover — includes ones you have NOT joined. */
export interface DiscoveredThread {
threadId: string;
subject: string | null;
metadata: Record<string, unknown> | null;
participantCount: number;
joined: boolean;
}
/** A "my threads" entry from GET /v1/threads (server-authoritative). */ /** A "my threads" entry from GET /v1/threads (server-authoritative). */
export interface ThreadSummary { export interface ThreadSummary {
threadId: string; threadId: string;
+65
View File
@@ -0,0 +1,65 @@
{
"name": "@insignia/iios-messaging-ui",
"version": "0.1.14",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./adapters/mock": {
"types": "./dist/adapters/mock.d.ts",
"import": "./dist/adapters/mock.js"
},
"./adapters/kernel-client": {
"types": "./dist/adapters/kernel-client.d.ts",
"import": "./dist/adapters/kernel-client.js"
},
"./adapters/mock-inbox": {
"types": "./dist/adapters/mock-inbox.d.ts",
"import": "./dist/adapters/mock-inbox.js"
},
"./conformance": {
"types": "./dist/conformance.d.ts",
"import": "./dist/conformance.js"
},
"./styles.css": "./dist/styles.css"
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"peerDependencies": {
"@insignia/iios-kernel-client": "*",
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"@insignia/iios-kernel-client": {
"optional": true
}
},
"devDependencies": {
"@insignia/iios-kernel-client": "workspace:*",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.1.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"jsdom": "^26.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tsup": "^8.3.5",
"typescript": "^5.7.3",
"vitest": "^3.0.5"
},
"publishConfig": {
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
}
}
+111
View File
@@ -0,0 +1,111 @@
import type {
Attachment,
BulkAddResult,
ChannelSummary,
Conversation,
CreateChannelInput,
Membership,
Message,
MessageEvent,
Person,
SendOpts,
Unsubscribe,
} from './types';
/**
* The one seam of this SDK. Hosts implement this; the SDK renders it.
*
* Lifted from lynkeduppro-crm's MessengerData/ThreadData, which already survived
* two implementations (live data-door + mock) — the minimum real evidence that a
* seam is genuine rather than imagined.
*
* Optional methods degrade gracefully: the UI hides the reaction picker when
* `react` is absent, and the attach button when `upload` is absent. That is how one
* component set serves both a full CRM messenger and a stripped-down widget with no
* `mode` prop.
*/
export interface MessagingAdapter {
listConversations(): Promise<Conversation[]>;
openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }>;
history(threadId: string): Promise<Message[]>;
send(threadId: string, content: string, opts?: SendOpts): Promise<Message>;
/** Returns an unsubscribe fn. Implementations MUST be idempotent on repeat unsubscribe. */
subscribe(threadId: string, cb: (e: MessageEvent) => void): Unsubscribe;
sendTyping(threadId: string): void;
markRead(threadId: string, messageId: string): Promise<void>;
/** Report which thread is in the foreground (null when none/blurred) so the backend can suppress
* push notifications for a thread you're actively viewing. Absent => presence isn't tracked. */
setFocus?(threadId: string | null): void;
/** Subscribe to activity across ALL of the caller's threads (not just the open one) — fires for
* every incoming message. Drives live unread + in-app notifications. Absent => no cross-thread
* awareness. Returns an unsubscribe fn. */
subscribeActivity?(cb: (e: { threadId: string; message: Message }) => void): Unsubscribe;
/**
* The current user's actor id, or null if not yet known.
*
* MUST NOT be inferred from message history. The CRM's bug was exactly that:
* scanning for a sent message meant every message read as not-yours until you
* had spoken. Adapters derive this from auth/session.
*/
currentActorId(): string | null;
/** Absent => the UI hides reactions entirely. */
react?(threadId: string, messageId: string, emoji: string): Promise<void>;
/** Absent => the UI hides attachments. Storage/auth/limits are the host's concern. */
upload?(file: File): Promise<Attachment>;
/** Absent => treated as always connected (e.g. a pure-REST adapter). */
isConnected?(): boolean;
/** A thread's members (id + display name), for @mention autocomplete + highlighting.
* Absent => the composer offers no autocomplete (you can still type @text). */
listMembers?(threadId: string): Promise<Person[]>;
/** The people you can start a conversation with (org directory). Drives the "New message"
* people picker. Absent => the UI hides DM/group creation (you can still open channels). */
directory?(): Promise<Person[]>;
// ── Channels (optional capability) ──────────────────────────────
// A channel is just a third membership beyond dm/group: a discoverable, joinable room.
// Implement all four to enable the channels UI; absent => the UI hides channels entirely.
/** Discoverable channels in the caller's scope, each flagged `joined`. */
browseChannels?(): Promise<ChannelSummary[]>;
/** Create a channel; the creator joins as admin. Returns the new thread id. */
createChannel?(input: CreateChannelInput): Promise<{ threadId: string }>;
/** Join a (public) channel by id. */
joinChannel?(threadId: string): Promise<void>;
/** Leave a channel by id. */
leaveChannel?(threadId: string): Promise<void>;
// ── Group / channel administration (optional) ───────────────────
// Enable the settings panel for groups + channels. Absent methods hide their affordance;
// the server (OPA) still enforces who may actually add/remove/rename (admin-only).
/** Add a person to a group or private channel. */
addMember?(threadId: string, userId: string): Promise<void>;
/** Add many people in one call — powers "add everyone from another channel". The host is expected
* to enforce who may add (and who may read the source roster) server-side.
* Absent => the import-from-a-channel affordance is hidden; single add still works. */
addMembers?(threadId: string, userIds: string[]): Promise<BulkAddResult>;
/** Remove a person from a group or channel. */
removeMember?(threadId: string, userId: string): Promise<void>;
/** Rename a group or channel. */
renameConversation?(threadId: string, subject: string): Promise<void>;
}
@@ -0,0 +1,153 @@
// Runs the shared adapter conformance suite against KernelClientAdapter — proving it satisfies
// the same contract as the mock, over the REAL MessageSocket facade. Only the lowest socket.io
// layer is faked (via kernel-client's own SocketLike seam), so the facade's wire mapping is
// exercised for real. No live IIOS required.
import { describe, it, expect } from 'vitest';
import { MessageSocket } from '@insignia/iios-kernel-client';
import type { Message as KernelMessage, SocketLike } from '@insignia/iios-kernel-client';
import { runAdapterConformance } from '../conformance';
import { KernelClientAdapter, type RestPort } from './kernel-client';
const ME = 'me';
const SEEDED = 'th_seed';
/** An in-memory stand-in for the /message socket.io namespace: stores messages, echoes 'message'. */
function makeFakeSocket(): SocketLike {
const threads = new Map<string, KernelMessage[]>([
[
SEEDED,
[
{
id: 'seed_1',
threadId: SEEDED,
senderActorId: 'actor_other',
senderId: 'pp_other',
senderName: 'Other',
content: 'seeded message',
createdAt: new Date(0).toISOString(),
},
],
],
]);
const handlers = new Map<string, Set<(...a: unknown[]) => void>>();
let seq = 1;
const fire = (event: string, payload: unknown): void => handlers.get(event)?.forEach((h) => h(payload));
return {
on(event, handler) {
if (!handlers.has(event)) handlers.set(event, new Set());
handlers.get(event)!.add(handler);
return undefined;
},
off(event, handler) {
handlers.get(event)?.delete(handler);
return undefined;
},
emit() {
return undefined; // typing/focus — no echo needed for conformance
},
async emitWithAck(event, payload) {
const p = (payload ?? {}) as {
threadId: string;
content?: string;
parentInteractionId?: string;
interactionId?: string;
type?: string;
value?: string;
};
if (event === 'open_thread') {
if (!threads.has(p.threadId)) threads.set(p.threadId, []);
return { threadId: p.threadId, status: 'OPEN', history: [...threads.get(p.threadId)!] };
}
if (event === 'send_message') {
const msg: KernelMessage = {
id: `m_${seq++}`,
threadId: p.threadId,
senderActorId: `actor_${ME}`,
senderId: ME,
senderName: ME,
content: p.content ?? '',
createdAt: new Date().toISOString(),
...(p.parentInteractionId ? { parentInteractionId: p.parentInteractionId } : {}),
};
if (!threads.has(p.threadId)) threads.set(p.threadId, []);
threads.get(p.threadId)!.push(msg);
fire('message', msg);
return msg;
}
if (event === 'read') return { ok: true };
if (event === 'annotate') {
fire('annotation', {
threadId: p.threadId,
interactionId: p.interactionId,
type: p.type,
value: p.value,
op: 'add',
users: [ME],
userId: ME,
});
return {};
}
return {};
},
connect() {
return undefined;
},
disconnect() {
return undefined;
},
connected: true,
};
}
function makeFakeRest(): RestPort {
let seq = 1;
return {
async listThreads() {
return [];
},
async createThread() {
return { threadId: `th_new_${seq++}` };
},
async addParticipant() {
/* governed server-side; a fake always allows */
},
async discoverThreads() {
return [
{
threadId: 'th_pub',
subject: 'general',
metadata: { membership: 'channel', visibility: 'public', topic: 'Company-wide' },
participantCount: 3,
joined: false,
},
];
},
async leaveThread(threadId) {
return { threadId, participantCount: 0 };
},
};
}
function makeAdapter(): KernelClientAdapter {
const socket = new MessageSocket({ serviceUrl: 'http://iios.test', token: 'tok', autoConnect: false }, makeFakeSocket());
return new KernelClientAdapter({ currentUserId: ME, socket, rest: makeFakeRest() });
}
runAdapterConformance({ makeAdapter, seededThreadId: SEEDED, openWith: ['pp_a'] });
describe('KernelClientAdapter channels', () => {
it('browse maps discovered public channels; create/join/leave delegate to the transport', async () => {
const adapter = makeAdapter();
const list = await adapter.browseChannels!();
expect(list[0]).toMatchObject({ threadId: 'th_pub', name: 'general', visibility: 'public', joined: false, memberCount: 3, topic: 'Company-wide' });
const { threadId } = await adapter.createChannel!({ name: 'design', topic: 'UI', visibility: 'public' });
expect(typeof threadId).toBe('string');
await expect(adapter.joinChannel!('th_pub')).resolves.toBeUndefined();
await expect(adapter.leaveChannel!('th_pub')).resolves.toBeUndefined();
});
});
@@ -0,0 +1,303 @@
// Transport adapter: implements MessagingAdapter over @insignia/iios-kernel-client
// (browser → IIOS directly, token-in). This is the "plug into any app with a token"
// path — the same transport chat-web and support-sdk use.
//
// It lives in adapters/ (the ONLY layer allowed to import a transport) and ships from
// its own subpath, so core UI never pulls socket code it can't use.
import { MessageSocket, RestClient } from '@insignia/iios-kernel-client';
import type {
AnnotationEvent,
DiscoveredThread,
Message as KernelMessage,
MessageEvents,
OpenThreadResult,
ReceiptEvent,
ThreadSummary,
TypingEvent,
} from '@insignia/iios-kernel-client';
import type { MessagingAdapter } from '../adapter';
import type {
ChannelSummary,
ChannelVisibility,
Conversation,
CreateChannelInput,
Membership,
Message,
MessageEvent,
Reaction,
SendOpts,
Unsubscribe,
} from '../types';
/** The slice of MessageSocket the adapter needs — the real facade satisfies it; tests inject a fake. */
export interface SocketPort {
openThread(threadId?: string, opts?: { membership?: string; creatorRole?: string; subject?: string }): Promise<OpenThreadResult>;
sendMessage(threadId: string, content: string, opts?: { parentInteractionId?: string; mentions?: string[] }): Promise<KernelMessage>;
react(threadId: string, interactionId: string, value: string): Promise<void>;
markRead(threadId: string, interactionId: string): Promise<{ ok: boolean }>;
typing(threadId: string): void;
on<E extends keyof MessageEvents>(event: E, handler: MessageEvents[E]): () => void;
}
/** The slice of RestClient the adapter needs. */
export interface RestPort {
listThreads(filter?: { metadata?: Record<string, string> }): Promise<ThreadSummary[]>;
createThread(opts: { membership?: string; creatorRole?: string; subject?: string; metadata?: Record<string, unknown> }): Promise<{ threadId: string }>;
addParticipant(threadId: string, userId: string): Promise<void>;
discoverThreads(filter?: { metadata?: Record<string, string> }): Promise<DiscoveredThread[]>;
leaveThread(threadId: string): Promise<{ threadId: string; participantCount: number }>;
}
export interface KernelClientAdapterConfig {
/**
* The current user's id in IIOS's `senderId` space (email/username), from the host's auth —
* NEVER inferred from message history. This is exactly `currentActorId()`, and it's the bug the
* conformance suite kills: identity comes from the session, not from a message you happened to send.
*/
currentUserId: string;
socket: SocketPort;
rest: RestPort;
/** Optional opaque metadata filter for the conversation list (e.g. { source: 'crm-messenger' }). */
threadFilter?: Record<string, string>;
}
const REACTION = 'reaction';
export class KernelClientAdapter implements MessagingAdapter {
private readonly me: string;
private readonly socket: SocketPort;
private readonly rest: RestPort;
private readonly threadFilter?: Record<string, string>;
/** Per-thread UI subscribers. The socket fans server events in; these fan them out. */
private readonly listeners = new Map<string, Set<(e: MessageEvent) => void>>();
/** Reaction users per message (messageId → emoji → userSet), so an annotation delta becomes a full set. */
private readonly reactions = new Map<string, Map<string, Set<string>>>();
private readonly joined = new Set<string>();
private readonly offs: Array<() => void> = [];
constructor(cfg: KernelClientAdapterConfig) {
this.me = cfg.currentUserId;
this.socket = cfg.socket;
this.rest = cfg.rest;
this.threadFilter = cfg.threadFilter;
this.offs.push(
this.socket.on('message', (m: KernelMessage) => {
this.ingestReactions(m);
this.emit(m.threadId, { kind: 'message', message: this.toMessage(m) });
}),
);
this.offs.push(
this.socket.on('typing', (e: TypingEvent) => this.emit(e.threadId, { kind: 'typing', userId: e.userId })),
);
this.offs.push(
// Receipts carry no threadId, so fan to every open thread; the UI filters by messageId.
this.socket.on('receipt', (e: ReceiptEvent) => this.broadcast({ kind: 'receipt', messageId: e.interactionId, actorId: e.actorId })),
);
this.offs.push(
this.socket.on('annotation', (e: AnnotationEvent) => {
if (e.type !== REACTION) return;
this.setReactionUsers(e.interactionId, e.value, e.users);
this.emit(e.threadId, { kind: 'reaction', messageId: e.interactionId, reactions: this.reactionsOf(e.interactionId) });
}),
);
}
currentActorId(): string {
return this.me;
}
async listConversations(): Promise<Conversation[]> {
const threads = await this.rest.listThreads(this.threadFilter ? { metadata: this.threadFilter } : undefined);
return threads.map((t) => this.toConversation(t));
}
async openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }> {
const membership: Membership = p.membership ?? (p.participantIds.length === 1 ? 'dm' : 'group');
const { threadId } = await this.rest.createThread({
membership,
creatorRole: membership === 'group' ? 'ADMIN' : 'MEMBER',
...(p.subject ? { subject: p.subject } : {}),
});
// Governance (DM cap, roles) is enforced server-side by IIOS/OPA; a rejected add surfaces up there.
for (const id of p.participantIds) await this.rest.addParticipant(threadId, id).catch(() => undefined);
return { threadId };
}
async history(threadId: string): Promise<Message[]> {
const res = await this.socket.openThread(threadId); // joins the thread, so live events start flowing
this.joined.add(threadId);
return res.history.map((m) => {
this.ingestReactions(m);
return this.toMessage(m);
});
}
async send(threadId: string, content: string, opts?: SendOpts): Promise<Message> {
// Attachments are intentionally not forwarded here: kernel-client exposes no media/presign yet,
// and the SDK Attachment carries a display `url`, not a storage `contentRef`. Media is a follow-up
// (kernel-client media methods + a contentRef on Attachment). Text + reply threading work today.
const sendOpts = {
...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}),
...(opts?.mentions && opts.mentions.length ? { mentions: opts.mentions } : {}),
};
const m = await this.socket.sendMessage(threadId, content, Object.keys(sendOpts).length ? sendOpts : undefined);
return this.toMessage(m);
}
async react(threadId: string, messageId: string, emoji: string): Promise<void> {
await this.socket.react(threadId, messageId, emoji);
}
subscribe(threadId: string, cb: (e: MessageEvent) => void): Unsubscribe {
if (!this.listeners.has(threadId)) this.listeners.set(threadId, new Set());
this.listeners.get(threadId)!.add(cb);
if (!this.joined.has(threadId)) {
this.joined.add(threadId);
void this.socket.openThread(threadId).catch(() => this.joined.delete(threadId));
}
return () => {
this.listeners.get(threadId)?.delete(cb);
};
}
sendTyping(threadId: string): void {
this.socket.typing(threadId);
}
async markRead(threadId: string, messageId: string): Promise<void> {
await this.socket.markRead(threadId, messageId);
}
// ── Channels ────────────────────────────────────────────────────
async browseChannels(): Promise<ChannelSummary[]> {
const found = await this.rest.discoverThreads({ metadata: { membership: 'channel', visibility: 'public' } });
return found.map((d) => {
const bag = (d.metadata as { topic?: string; visibility?: string } | null) ?? {};
const visibility: ChannelVisibility = bag.visibility === 'private' ? 'private' : 'public';
return {
threadId: d.threadId,
name: d.subject ?? 'channel',
topic: bag.topic ?? null,
visibility,
memberCount: d.participantCount,
joined: d.joined,
};
});
}
async createChannel(input: CreateChannelInput): Promise<{ threadId: string }> {
return this.rest.createThread({
membership: 'channel',
creatorRole: 'ADMIN',
subject: input.name,
metadata: { visibility: input.visibility, ...(input.topic ? { topic: input.topic } : {}) },
});
}
async joinChannel(threadId: string): Promise<void> {
// Self-join is a governed open_thread; OPA allows it for a public channel.
await this.socket.openThread(threadId);
this.joined.add(threadId);
}
async leaveChannel(threadId: string): Promise<void> {
await this.rest.leaveThread(threadId);
this.joined.delete(threadId);
}
/** Detach socket handlers. Not part of the contract — call on teardown to avoid leaks. */
close(): void {
for (const off of this.offs) off();
this.offs.length = 0;
this.listeners.clear();
}
// ── mapping ────────────────────────────────────────────────────
private toMessage(m: KernelMessage): Message {
return {
id: m.id,
// `senderId` (email/username), NOT the actor id — it matches currentActorId() and is the
// reliable "is this mine?" field. Never inferred from history.
actorId: m.senderId ?? null,
text: m.content ?? '',
at: m.createdAt,
parentInteractionId: m.parentInteractionId ?? null,
reactions: this.reactionsOf(m.id),
};
}
private toConversation(t: ThreadSummary): Conversation {
const others = t.participants.filter((p) => p !== this.me);
const membership: Membership | null =
t.membership === 'dm' || t.membership === 'group' || t.membership === 'channel' ? t.membership : null;
const topic = (t.metadata as { topic?: string } | null)?.topic;
return {
threadId: t.threadId,
title: t.subject?.trim() || others.join(', ') || 'Conversation',
subject: t.subject,
membership,
participants: [...t.participants],
unread: t.unread,
...(topic != null ? { topic } : {}),
...(t.lastMessage ? { lastMessage: t.lastMessage } : {}),
...(t.lastAt ? { lastAt: t.lastAt } : {}),
};
}
// ── reaction state ─────────────────────────────────────────────
private ingestReactions(m: KernelMessage): void {
for (const a of m.annotations ?? []) {
if (a.type === REACTION) this.setReactionUsers(m.id, a.value, a.users);
}
}
private setReactionUsers(messageId: string, emoji: string, users: string[]): void {
let byEmoji = this.reactions.get(messageId);
if (!byEmoji) {
byEmoji = new Map();
this.reactions.set(messageId, byEmoji);
}
if (users.length === 0) byEmoji.delete(emoji);
else byEmoji.set(emoji, new Set(users));
}
private reactionsOf(messageId: string): Reaction[] {
const byEmoji = this.reactions.get(messageId);
if (!byEmoji) return [];
const out: Reaction[] = [];
for (const [emoji, users] of byEmoji) {
if (users.size > 0) out.push({ emoji, count: users.size, mine: users.has(this.me) });
}
return out;
}
// ── event fan-out ──────────────────────────────────────────────
private emit(threadId: string, e: MessageEvent): void {
this.listeners.get(threadId)?.forEach((cb) => cb(e));
}
private broadcast(e: MessageEvent): void {
for (const set of this.listeners.values()) set.forEach((cb) => cb(e));
}
}
/** Convenience: build an adapter that talks straight to IIOS with a token. */
export function connectKernelAdapter(opts: {
serviceUrl: string;
token: string;
currentUserId: string;
threadFilter?: Record<string, string>;
autoConnect?: boolean;
}): KernelClientAdapter {
const rest = new RestClient({ serviceUrl: opts.serviceUrl, token: opts.token });
const socket = new MessageSocket({ serviceUrl: opts.serviceUrl, token: opts.token, autoConnect: opts.autoConnect ?? true });
return new KernelClientAdapter({
currentUserId: opts.currentUserId,
socket,
rest,
...(opts.threadFilter ? { threadFilter: opts.threadFilter } : {}),
});
}
@@ -0,0 +1,114 @@
import type { InboxAdapter } from '../inbox/adapter';
import type { InboxItem, InboxState, MailAttachment, MailMessage, MailPerson } from '../inbox/types';
const PEOPLE: MailPerson[] = [
{ id: 'pp_sofia', name: 'Sofia Ramirez', kind: 'staff' },
{ id: 'pp_dan', name: 'Dan Whitaker', kind: 'staff' },
{ id: 'cust_acme', name: 'Acme Roofing (Client)', kind: 'customer' },
];
interface MockMail {
subject: string;
messages: MailMessage[];
}
/**
* In-memory inbox for demos/tests. State is PER INSTANCE. Seeds a few work items (mention,
* needs-reply, alert) plus mail threads that fold into the Open view as MAIL rows.
*/
export class MockInboxAdapter implements InboxAdapter {
private seq = 100;
private readonly items: InboxItem[];
private readonly threads = new Map<string, MockMail>();
/** threadIds that surface as standalone MAIL rows (in addition to work items). */
private readonly mailFold = ['mt_welcome', 'mt_invoice'];
constructor(private readonly now: () => string = () => new Date().toISOString()) {
const at = this.now();
this.items = [
{ id: 'in_1', kind: 'MENTION', state: 'OPEN', title: 'Sofia mentioned you', summary: '@you — can you confirm the Henderson scope?', priority: 'HIGH', threadId: 'mt_mention', createdAt: at },
{ id: 'in_2', kind: 'NEEDS_REPLY', state: 'OPEN', title: 'Reply needed — Storm response', summary: 'Dan: crew rolling out at 7', priority: 'MEDIUM', threadId: 'mt_storm', createdAt: at },
{ id: 'in_3', kind: 'SYSTEM_ALERT', state: 'OPEN', title: 'Ticket TK-204 updated', summary: 'Customer replied on the roof-leak case.', priority: 'LOW', createdAt: at },
];
this.threads.set('mt_mention', {
subject: 'Henderson scope',
messages: [{ id: 'm1', actorId: 'pp_sofia', kind: 'EMAIL', at, html: '<p>Can you confirm the <b>Henderson</b> scope by EOD?</p>', text: 'Can you confirm the Henderson scope by EOD?', attachment: null }],
});
this.threads.set('mt_storm', {
subject: 'Storm response — East side',
messages: [{ id: 'm2', actorId: 'pp_dan', kind: 'EMAIL', at, html: '<p>Crew is rolling out at 7. Confirm the Henderson job?</p>', text: 'Crew rolling out at 7. Confirm the Henderson job?', attachment: null }],
});
this.threads.set('mt_welcome', {
subject: 'Welcome to the Founders Club',
messages: [{ id: 'm3', actorId: 'system', kind: 'EMAIL', at, html: '<p>Thanks for joining the <b>Founders Club</b>. Set up your account to get started.</p>', text: 'Thanks for joining the Founders Club.', attachment: null }],
});
this.threads.set('mt_invoice', {
subject: 'Invoice #1042 — Acme Roofing',
messages: [{ id: 'm4', actorId: 'cust_acme', kind: 'EMAIL', at, html: '<p>Attached is invoice <b>#1042</b> for the East-side job.</p>', text: 'Attached is invoice #1042 for the East-side job.', attachment: null }],
});
}
async listInbox(state?: InboxState): Promise<InboxItem[]> {
const showMail = !state || state === 'OPEN';
const work = this.items.filter((i) => (state ? i.state === state : true));
const mail: InboxItem[] = showMail
? this.mailFold.map((tid) => {
const t = this.threads.get(tid)!;
const last = t.messages[t.messages.length - 1];
return {
id: `mail:${tid}`,
kind: 'MAIL',
state: 'OPEN' as InboxState,
title: t.subject,
...(last?.text ? { summary: last.text } : {}),
priority: 'LOW',
threadId: tid,
createdAt: last?.at ?? this.now(),
};
})
: [];
return [...mail, ...work];
}
async transition(id: string, state: InboxState): Promise<void> {
const item = this.items.find((i) => i.id === id);
if (item) item.state = state;
}
async mailHistory(threadId: string): Promise<MailMessage[]> {
return [...(this.threads.get(threadId)?.messages ?? [])];
}
async mailReply(threadId: string, content: string, attachment?: MailAttachment): Promise<void> {
const t = this.threads.get(threadId);
if (t) t.messages = [...t.messages, { id: `r_${this.seq++}`, actorId: 'me', kind: 'MESSAGE', at: this.now(), html: null, text: content || null, attachment: attachment ?? null }];
}
async uploadAttachment(file: File): Promise<MailAttachment> {
return { contentRef: `mock/${this.seq++}`, mimeType: file.type || 'application/octet-stream', sizeBytes: file.size, filename: file.name };
}
async downloadAttachment(attachment: MailAttachment): Promise<string> {
// Demo: no real bytes — hand back a data URL so the click resolves without a network call.
return `data:${attachment.mimeType};base64,`;
}
async directory(): Promise<MailPerson[]> {
return [...PEOPLE];
}
async composeInternal(recipientUserId: string, subject: string, text: string, attachments?: MailAttachment[]): Promise<void> {
const threadId = `mt_${this.seq++}`;
this.threads.set(threadId, { subject, messages: [{ id: `m_${this.seq++}`, actorId: 'me', kind: 'EMAIL', at: this.now(), html: `<p>${text}</p>`, text, attachment: attachments?.[0] ?? null }] });
this.mailFold.unshift(threadId);
void recipientUserId;
}
async composeExternal(target: string, subject: string, text: string, attachments?: MailAttachment[]): Promise<void> {
// A mock external send has no in-app thread — no-op beyond acknowledging.
void target;
void subject;
void text;
void attachments;
}
}
@@ -0,0 +1,285 @@
import type { MessagingAdapter } from '../adapter';
import type {
Attachment,
BulkAddResult,
ChannelSummary,
ChannelVisibility,
Conversation,
CreateChannelInput,
Membership,
Message,
MessageEvent,
Person,
SendOpts,
Unsubscribe,
} from '../types';
const ME = 'me';
export const MOCK_PEOPLE: Person[] = [
{ id: 'pp_sofia', name: 'Sofia Ramirez', kind: 'staff' },
{ id: 'pp_dan', name: 'Dan Whitaker', kind: 'staff' },
{ id: 'pp_priya', name: 'Priya Nair', kind: 'staff' },
{ id: 'cust_acme', name: 'Acme Roofing (Client)', kind: 'customer' },
{ id: 'cust_globex', name: 'Globex Homes (Client)', kind: 'customer' },
];
interface MockThread {
threadId: string;
membership: Membership;
subject: string | null;
participants: string[];
messages: Message[];
topic?: string | null;
visibility?: ChannelVisibility;
}
const nameById = new Map(MOCK_PEOPLE.map((p) => [p.id, p.name]));
/**
* In-memory adapter for demos and tests. State is PER INSTANCE — the CRM's version
* used a module-level Map, which leaks between tests. Each `new MockAdapter()` is
* fully isolated.
*/
export class MockAdapter implements MessagingAdapter {
private seq = 100;
private threads = new Map<string, MockThread>();
private listeners = new Map<string, Set<(e: MessageEvent) => void>>();
constructor(private readonly now: () => string = () => new Date().toISOString()) {
const seededAt = this.now();
this.threads.set('th_mock_1', {
threadId: 'th_mock_1',
membership: 'dm',
subject: null,
participants: [ME, 'pp_sofia'],
messages: [
{
id: 'm1',
actorId: 'pp_sofia',
text: 'Can you review the Henderson estimate?',
at: seededAt,
reactions: [],
},
],
});
this.threads.set('th_mock_2', {
threadId: 'th_mock_2',
membership: 'group',
subject: 'Storm response — East side',
participants: [ME, 'pp_dan', 'pp_priya'],
messages: [
{ id: 'm2', actorId: 'pp_dan', text: 'Crew is rolling out at 7.', at: seededAt, reactions: [] },
],
});
// Channels: a joined public one, a joinable public one (I'm NOT in it → shows in browse only),
// and a private one I'm a member of.
this.threads.set('th_ch_general', {
threadId: 'th_ch_general', membership: 'channel', subject: 'general', topic: 'Company-wide chatter',
visibility: 'public', participants: [ME, 'pp_dan', 'pp_priya', 'pp_sofia'],
messages: [{ id: 'c1', actorId: 'pp_priya', text: 'Welcome to #general 👋', at: seededAt, reactions: [] }],
});
this.threads.set('th_ch_random', {
threadId: 'th_ch_random', membership: 'channel', subject: 'random', topic: 'Non-work banter',
visibility: 'public', participants: ['pp_dan', 'pp_sofia'], messages: [],
});
this.threads.set('th_ch_deals', {
threadId: 'th_ch_deals', membership: 'channel', subject: 'deals', topic: 'Big pipeline moves',
visibility: 'private', participants: [ME, 'pp_sofia'], messages: [],
});
}
currentActorId(): string {
return ME;
}
async listConversations(): Promise<Conversation[]> {
// Only threads I'm a member of — an un-joined public channel appears in browse, not here.
return [...this.threads.values()]
.filter((t) => t.participants.includes(ME))
.map((t) => {
const last = t.messages[t.messages.length - 1];
const others = t.participants.filter((p) => p !== ME);
return {
threadId: t.threadId,
title: t.subject || others.map((id) => nameById.get(id) ?? id).join(', ') || 'Conversation',
subject: t.subject,
membership: t.membership,
participants: [...t.participants],
unread: 0,
...(t.topic != null ? { topic: t.topic } : {}),
...(last ? { lastMessage: last.text, lastAt: last.at } : {}),
};
});
}
async browseChannels(): Promise<ChannelSummary[]> {
// Public channels are discoverable; private ones only if I'm already a member.
return [...this.threads.values()]
.filter((t) => t.membership === 'channel' && (t.visibility === 'public' || t.participants.includes(ME)))
.map((t) => ({
threadId: t.threadId,
name: t.subject ?? 'channel',
topic: t.topic ?? null,
visibility: t.visibility ?? 'public',
memberCount: t.participants.length,
joined: t.participants.includes(ME),
}));
}
async createChannel(input: CreateChannelInput): Promise<{ threadId: string }> {
const threadId = `th_ch_${this.seq++}`;
this.threads.set(threadId, {
threadId,
membership: 'channel',
subject: input.name,
topic: input.topic ?? null,
visibility: input.visibility,
participants: [ME],
messages: [],
});
return { threadId };
}
async joinChannel(threadId: string): Promise<void> {
const t = this.threads.get(threadId);
if (t && !t.participants.includes(ME)) t.participants = [...t.participants, ME];
}
async leaveChannel(threadId: string): Promise<void> {
const t = this.threads.get(threadId);
if (t) t.participants = t.participants.filter((p) => p !== ME);
}
async listMembers(threadId: string): Promise<Person[]> {
const t = this.threads.get(threadId);
if (!t) return [];
return t.participants.map((id) => {
if (id === ME) return { id: ME, name: 'You', kind: 'staff' };
return MOCK_PEOPLE.find((p) => p.id === id) ?? { id, name: id, kind: 'staff' };
});
}
async directory(): Promise<Person[]> {
return MOCK_PEOPLE.map((p) => ({ ...p }));
}
async addMember(threadId: string, userId: string): Promise<void> {
const t = this.threads.get(threadId);
if (t && !t.participants.includes(userId)) t.participants = [...t.participants, userId];
}
/** Bulk add, mirroring the live door: already-members come back as `skipped`, never re-added. */
async addMembers(threadId: string, userIds: string[]): Promise<BulkAddResult> {
const t = this.threads.get(threadId);
if (!t) return { added: [], skipped: [], failed: [...userIds] };
const added: string[] = [];
const skipped: string[] = [];
for (const id of [...new Set(userIds)]) {
if (t.participants.includes(id)) skipped.push(id);
else {
t.participants = [...t.participants, id];
added.push(id);
}
}
return { added, skipped, failed: [] };
}
async removeMember(threadId: string, userId: string): Promise<void> {
const t = this.threads.get(threadId);
if (t) t.participants = t.participants.filter((p) => p !== userId);
}
async renameConversation(threadId: string, subject: string): Promise<void> {
const t = this.threads.get(threadId);
if (t) t.subject = subject;
}
async openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }> {
// A DM to someone you already have reuses the existing 1:1 thread (dedupe, like the live door).
if ((p.membership ?? (p.participantIds.length === 1 ? 'dm' : 'group')) === 'dm' && p.participantIds.length === 1) {
const target = p.participantIds[0];
for (const [id, t] of this.threads) {
if (t.membership === 'dm' && t.participants.length === 2 && t.participants.includes(ME) && t.participants.includes(target)) {
return { threadId: id };
}
}
}
const membership = p.membership ?? (p.participantIds.length === 1 ? 'dm' : 'group');
const threadId = `th_mock_${this.seq++}`;
this.threads.set(threadId, {
threadId,
membership,
subject: p.subject ?? null,
participants: [ME, ...p.participantIds],
messages: [],
});
return { threadId };
}
async history(threadId: string): Promise<Message[]> {
return [...(this.threads.get(threadId)?.messages ?? [])];
}
async send(threadId: string, content: string, opts?: SendOpts): Promise<Message> {
const t = this.threads.get(threadId);
if (!t) throw new Error(`Unknown thread: ${threadId}`);
const message: Message = {
id: `m_${this.seq++}`,
actorId: ME,
text: content,
at: this.now(),
reactions: [],
...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}),
...(opts?.attachment ? { attachment: opts.attachment } : {}),
};
t.messages = [...t.messages, message];
this.emit(threadId, { kind: 'message', message });
return message;
}
async react(threadId: string, messageId: string, emoji: string): Promise<void> {
const t = this.threads.get(threadId);
if (!t) return;
let next: Message | undefined;
t.messages = t.messages.map((m) => {
if (m.id !== messageId) return m;
const existing = (m.reactions ?? []).find((r) => r.emoji === emoji);
const reactions = existing
? (m.reactions ?? []).filter((r) => r.emoji !== emoji)
: [...(m.reactions ?? []), { emoji, count: 1, mine: true }];
next = { ...m, reactions };
return next;
});
if (next) this.emit(threadId, { kind: 'reaction', messageId, reactions: next.reactions ?? [] });
}
async upload(file: File): Promise<Attachment> {
return { url: `mock://uploads/${file.name}`, mime: file.type, name: file.name };
}
subscribe(threadId: string, cb: (e: MessageEvent) => void): Unsubscribe {
if (!this.listeners.has(threadId)) this.listeners.set(threadId, new Set());
this.listeners.get(threadId)!.add(cb);
return () => {
this.listeners.get(threadId)?.delete(cb);
};
}
sendTyping(): void {
// No-op: nobody is typing back in a mock.
}
/** Echo a receipt the way a real server does, so the "ignore my own read" path is exercised. */
async markRead(threadId: string, messageId: string): Promise<void> {
this.emit(threadId, { kind: 'receipt', messageId, actorId: ME });
}
isConnected(): boolean {
return true;
}
private emit(threadId: string, e: MessageEvent): void {
this.listeners.get(threadId)?.forEach((cb) => cb(e));
}
}
@@ -0,0 +1,41 @@
// @vitest-environment node
import { describe, it, expect } from 'vitest';
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { join } from 'node:path';
// This package is ESM ("type": "module") — __dirname does not exist here.
const SRC = fileURLToPath(new URL('.', import.meta.url));
const ADAPTERS = join(SRC, 'adapters');
function tsFilesIn(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) out.push(...tsFilesIn(full));
else if (/\.tsx?$/.test(full)) out.push(full);
}
return out;
}
// The whole premise of this package: core renders, adapters transport. If core ever
// imports a transport, an app on a different backend pays for socket code it cannot
// use — which is exactly how iios-message-web welded itself to MessageSocket.
describe('transport boundary', () => {
const coreFiles = tsFilesIn(SRC).filter((f) => !f.startsWith(ADAPTERS) && !/\.test\.tsx?$/.test(f));
it('has core files to check', () => {
expect(coreFiles.length).toBeGreaterThan(0);
});
it('core never imports a transport package', () => {
const offenders: string[] = [];
for (const file of coreFiles) {
const content = readFileSync(file, 'utf8');
if (/@insignia\/iios-kernel-client|socket\.io|@abe-kap\/appshell-sdk/.test(content)) {
offenders.push(file.replace(SRC, 'src'));
}
}
expect(offenders).toEqual([]);
});
});
@@ -0,0 +1,93 @@
import { useState, type FormEvent } from 'react';
import { useChannels } from '../hooks/use-channels';
import type { ChannelVisibility } from '../types';
/** Browse + join discoverable channels, and create a new one. Shown in the main pane. */
export function ChannelBrowser({ onJoined }: { onJoined?: (threadId: string) => void }) {
const { browsable, loading, error, join, create } = useChannels();
const [name, setName] = useState('');
const [topic, setTopic] = useState('');
const [visibility, setVisibility] = useState<ChannelVisibility>('public');
const [busy, setBusy] = useState(false);
async function submitCreate(e: FormEvent): Promise<void> {
e.preventDefault();
const n = name.trim();
if (!n || busy) return;
setBusy(true);
try {
const threadId = await create({ name: n, ...(topic.trim() ? { topic: topic.trim() } : {}), visibility });
setName('');
setTopic('');
onJoined?.(threadId);
} catch {
// surfaced via the hook's error
} finally {
setBusy(false);
}
}
async function doJoin(threadId: string): Promise<void> {
await join(threadId);
onJoined?.(threadId);
}
return (
<div className="miu-browser">
<div className="miu-browser-head">Channels</div>
<form className="miu-channel-create" onSubmit={submitCreate}>
<input
className="miu-input"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="New channel name"
aria-label="Channel name"
/>
<input
className="miu-input"
value={topic}
onChange={(e) => setTopic(e.target.value)}
placeholder="Topic (optional)"
aria-label="Channel topic"
/>
<div className="miu-channel-vis">
<label>
<input type="radio" name="miu-vis" checked={visibility === 'public'} onChange={() => setVisibility('public')} /> Public
</label>
<label>
<input type="radio" name="miu-vis" checked={visibility === 'private'} onChange={() => setVisibility('private')} /> Private
</label>
</div>
<button type="submit" className="miu-send" disabled={!name.trim() || busy}>
Create
</button>
</form>
<div className="miu-browser-list">
{loading && browsable.length === 0 ? <div className="miu-empty">Loading</div> : null}
{error ? <div className="miu-empty miu-error">{error}</div> : null}
{!loading && browsable.length === 0 ? <div className="miu-empty">No channels yet create one above.</div> : null}
{browsable.map((c) => (
<div key={c.threadId} className="miu-browser-row">
<span className="miu-channel-glyph" aria-hidden="true">{c.visibility === 'private' ? '🔒' : '#'}</span>
<span className="miu-browser-main">
<span className="miu-browser-name">{c.name}</span>
{c.topic ? <span className="miu-browser-topic">{c.topic}</span> : null}
<span className="miu-browser-meta">
{c.memberCount} member{c.memberCount === 1 ? '' : 's'}
</span>
</span>
{c.joined ? (
<span className="miu-browser-joined">Joined</span>
) : (
<button type="button" className="miu-join" onClick={() => void doJoin(c.threadId)}>
Join
</button>
)}
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MessagingProvider } from '../provider';
import { Messenger } from './messenger';
import { MockAdapter } from '../adapters/mock';
function mount() {
return render(
<MessagingProvider adapter={new MockAdapter()}>
<Messenger />
</MessagingProvider>,
);
}
describe('channels (mock adapter)', () => {
it('browse returns public channels + private ones I am in, with a joined flag', async () => {
const a = new MockAdapter();
const list = await a.browseChannels();
const byId = new Map(list.map((c) => [c.threadId, c]));
expect(byId.get('th_ch_general')?.joined).toBe(true); // public, I'm in
expect(byId.get('th_ch_random')?.joined).toBe(false); // public, I'm NOT in
expect(byId.get('th_ch_deals')?.joined).toBe(true); // private, I'm in
// A private channel I'm not in must never surface in browse — none seeded, so all private here are mine.
expect(list.every((c) => c.visibility === 'public' || c.joined)).toBe(true);
});
it('join adds me and the channel then appears in my conversation list', async () => {
const a = new MockAdapter();
expect((await a.listConversations()).some((c) => c.threadId === 'th_ch_random')).toBe(false);
await a.joinChannel('th_ch_random');
expect((await a.listConversations()).some((c) => c.threadId === 'th_ch_random')).toBe(true);
expect((await a.browseChannels()).find((c) => c.threadId === 'th_ch_random')?.joined).toBe(true);
});
it('create makes a channel I am a member of', async () => {
const a = new MockAdapter();
const { threadId } = await a.createChannel({ name: 'design', topic: 'UI stuff', visibility: 'public' });
const conv = (await a.listConversations()).find((c) => c.threadId === threadId);
expect(conv?.membership).toBe('channel');
expect(conv?.topic).toBe('UI stuff');
});
it('UI: browse, then join a channel — it moves into the Channels section', async () => {
mount();
// Open the browser via the Channels section "+".
fireEvent.click(await screen.findByTitle('Browse channels'));
// #random is browse-only (not joined) → has a Join button.
expect(await screen.findByText('random')).toBeTruthy();
const joinButtons = screen.getAllByText('Join');
fireEvent.click(joinButtons[0]!);
// After joining, we jump to the thread and the browser closes → composer is shown.
await waitFor(() => expect(screen.getByLabelText('Message')).toBeTruthy());
});
});
@@ -0,0 +1,79 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { Composer } from './composer';
function mount(onSend = vi.fn().mockResolvedValue(undefined)) {
render(<Composer members={[]} canUpload={false} upload={vi.fn()} onSend={onSend} />);
return { onSend, box: screen.getByLabelText('Message') as HTMLTextAreaElement };
}
describe('<Composer /> formatting + native text services', () => {
it('is a textarea with the platform spellchecker enabled', () => {
const { box } = mount();
expect(box.tagName).toBe('TEXTAREA');
// The red squiggle + right-click suggestions come from the OS via these attributes.
expect(box.getAttribute('spellcheck')).toBe('true');
expect(box.getAttribute('autocorrect')).toBe('on');
});
it('does NOT reuse .miu-textarea — that class belongs to the inbox mail composer', () => {
// Regression: sharing it let the mail rule (resize: vertical; min-height: 90px), which is
// declared later in the stylesheet, win at equal specificity and inflate the chat composer.
const { box } = mount();
expect(box.classList.contains('miu-composer-box')).toBe(true);
expect(box.classList.contains('miu-textarea')).toBe(false);
});
it('Enter sends, Shift+Enter does not (it makes a newline)', async () => {
const { onSend, box } = mount();
fireEvent.change(box, { target: { value: 'hello' } });
fireEvent.keyDown(box, { key: 'Enter', shiftKey: true });
expect(onSend).not.toHaveBeenCalled();
fireEvent.keyDown(box, { key: 'Enter' });
await waitFor(() => expect(onSend).toHaveBeenCalledWith('hello', expect.anything()));
});
it('does not send mid-IME composition', () => {
const { onSend, box } = mount();
fireEvent.change(box, { target: { value: 'にほん' } });
fireEvent.keyDown(box, { key: 'Enter', isComposing: true });
expect(onSend).not.toHaveBeenCalled();
});
it('a toolbar button wraps the current selection in its marker', () => {
const { box } = mount();
fireEvent.change(box, { target: { value: 'make me bold' } });
box.setSelectionRange(8, 12); // "bold"
fireEvent.click(screen.getByLabelText('Bold (⌘B)'));
expect(box.value).toBe('make me *bold*');
});
it('⌘B / ⌘I wrap the selection too', () => {
const { box } = mount();
fireEvent.change(box, { target: { value: 'hello world' } });
box.setSelectionRange(0, 5);
fireEvent.keyDown(box, { key: 'b', metaKey: true });
expect(box.value).toBe('*hello* world');
box.setSelectionRange(8, 13); // "world" shifted by the two markers
fireEvent.keyDown(box, { key: 'i', ctrlKey: true });
expect(box.value).toBe('*hello* _world_');
});
it('with nothing selected, a marker pair is inserted at the caret', () => {
const { box } = mount();
fireEvent.change(box, { target: { value: 'ab' } });
box.setSelectionRange(2, 2);
fireEvent.click(screen.getByLabelText('Italic (⌘I)'));
expect(box.value).toBe('ab__');
});
it('sends the raw markers as plain text — formatting is a render concern', async () => {
const { onSend, box } = mount();
fireEvent.change(box, { target: { value: 'ship *today*' } });
fireEvent.keyDown(box, { key: 'Enter' });
await waitFor(() => expect(onSend).toHaveBeenCalledWith('ship *today*', expect.anything()));
});
});
@@ -0,0 +1,228 @@
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type FormEvent, type KeyboardEvent } from 'react';
import {
SPECIAL_MENTIONS,
insertMention,
resolveMentions,
trailingMentionQuery,
} from '../mentions';
import type { Attachment, Person, SendOpts } from '../types';
interface Suggestion {
key: string;
label: string;
insert: string;
}
/** Keyboard shortcut → the marker it wraps the selection in. */
const SHORTCUTS: Record<string, string> = { b: '*', i: '_', e: '`' };
/** Toolbar affordances for the same markers (strikethrough is button-only — no common shortcut). */
const FORMAT_BUTTONS: Array<{ marker: string; label: string; title: string }> = [
{ marker: '*', label: 'B', title: 'Bold (⌘B)' },
{ marker: '_', label: 'I', title: 'Italic (⌘I)' },
{ marker: '~', label: 'S', title: 'Strikethrough' },
{ marker: '`', label: '‹›', title: 'Code (⌘E)' },
];
/**
* The message input: draft, @mention autocomplete, and attachment staging. Shared by the main
* Thread and the ThreadPane (which passes a parentInteractionId so a reply lands in the thread).
*/
export function Composer({
members,
canUpload,
upload,
onSend,
onTyping,
parentInteractionId,
placeholder = 'Type a message… @ to mention, *bold*',
}: {
members: Person[];
canUpload: boolean;
upload: (file: File) => Promise<Attachment>;
onSend: (text: string, opts?: SendOpts) => Promise<void>;
onTyping?: () => void;
parentInteractionId?: string;
placeholder?: string;
}) {
const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false);
const [staged, setStaged] = useState<Attachment | null>(null);
const [uploading, setUploading] = useState(false);
const [uploadingName, setUploadingName] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
// Grow with the content up to the CSS max-height, then scroll — a chat box, not a fixed field.
// The box is border-box, but scrollHeight excludes the border, so add it back or every measure
// lands a couple of pixels short and the textarea shows a scrollbar it doesn't need.
useEffect(() => {
const el = inputRef.current;
if (!el) return;
el.style.height = 'auto';
const border = el.offsetHeight - el.clientHeight;
el.style.height = `${el.scrollHeight + border}px`;
}, [draft]);
/**
* Wrap the current selection in a marker (or, with nothing selected, drop in an empty pair and
* park the caret inside). Text stays plain — the marker characters ARE the format, so this never
* needs a rich-text model and the native spellchecker keeps working on the raw string.
*/
function wrapSelection(marker: string): void {
const el = inputRef.current;
if (!el) return;
const start = el.selectionStart ?? draft.length;
const end = el.selectionEnd ?? start;
const selected = draft.slice(start, end);
const next = `${draft.slice(0, start)}${marker}${selected}${marker}${draft.slice(end)}`;
setDraft(next);
// Restore a sensible selection after React re-renders the value.
const caret = selected ? start + marker.length + selected.length + marker.length : start + marker.length;
requestAnimationFrame(() => {
el.focus();
el.setSelectionRange(selected ? start + marker.length : caret, selected ? caret - marker.length : caret);
});
}
const query = trailingMentionQuery(draft);
const suggestions = useMemo<Suggestion[]>(() => {
if (query === null) return [];
const q = query.toLowerCase();
const specials = SPECIAL_MENTIONS.filter((s) => s.startsWith(q)).map((s) => ({ key: `@${s}`, label: `@${s}`, insert: s }));
const people = members.filter((m) => m.name.toLowerCase().includes(q)).map((m) => ({ key: m.id, label: m.name, insert: m.name }));
return [...specials, ...people].slice(0, 6);
}, [query, members]);
const showSuggest = query !== null && suggestions.length > 0;
function pick(insert: string): void {
setDraft((d) => insertMention(d, insert));
}
async function onPickFile(e: ChangeEvent<HTMLInputElement>): Promise<void> {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
setUploading(true);
setUploadingName(file.name);
try {
setStaged(await upload(file));
} catch {
/* host surfaces upload errors */
} finally {
setUploading(false);
setUploadingName(null);
}
}
async function submit(e?: FormEvent): Promise<void> {
e?.preventDefault();
const text = draft.trim();
if ((!text && !staged) || sending) return;
const mentions = resolveMentions(text, members);
const att = staged;
setDraft('');
setStaged(null);
setSending(true);
try {
await onSend(text, {
...(mentions.length ? { mentions } : {}),
...(att ? { attachment: att } : {}),
...(parentInteractionId ? { parentInteractionId } : {}),
});
} catch {
setStaged(att);
} finally {
setSending(false);
}
}
function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>): void {
// Formatting shortcuts, matching the toolbar. Cmd on macOS, Ctrl elsewhere.
if (e.metaKey || e.ctrlKey) {
const marker = SHORTCUTS[e.key.toLowerCase()];
if (marker) {
e.preventDefault();
wrapSelection(marker);
return;
}
}
if (e.key !== 'Enter') return;
if (showSuggest) {
e.preventDefault();
pick(suggestions[0]!.insert);
return;
}
// Enter sends; Shift+Enter (and IME composition) inserts a newline.
if (!e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
void submit();
}
}
return (
<form className="miu-composer" onSubmit={submit}>
{showSuggest ? (
<ul className="miu-suggest" role="listbox" aria-label="Mention suggestions">
{suggestions.map((s) => (
<li key={s.key}>
<button type="button" role="option" aria-selected="false" className="miu-suggest-item" onClick={() => pick(s.insert)}>
{s.label}
</button>
</li>
))}
</ul>
) : null}
{uploading && uploadingName ? (
<div className="miu-staged is-uploading">
<span className="miu-spinner" aria-hidden="true" /> {uploadingName} · uploading
</div>
) : staged ? (
<div className="miu-staged">
📎 {staged.name}
<button type="button" className="miu-staged-x" onClick={() => setStaged(null)} aria-label="Remove attachment">
</button>
</div>
) : null}
<div className="miu-format-bar" role="group" aria-label="Formatting">
{FORMAT_BUTTONS.map((b) => (
<button key={b.marker} type="button" className="miu-format-btn" title={b.title} aria-label={b.title} onClick={() => wrapSelection(b.marker)}>
{b.label}
</button>
))}
</div>
<div className="miu-composer-row">
{canUpload ? (
<>
<input ref={fileRef} type="file" className="miu-file-input" onChange={onPickFile} aria-label="Attach a file" />
<button type="button" className="miu-attach-btn" title="Attach a file" disabled={uploading} onClick={() => fileRef.current?.click()}>
{uploading ? '…' : '📎'}
</button>
</>
) : null}
<textarea
ref={inputRef}
className="miu-input miu-composer-box"
value={draft}
placeholder={placeholder}
aria-label="Message"
rows={1}
// Native platform text services: the browser/OS supplies the red squiggle, the right-click
// suggestions and "Add to dictionary", and mobile keyboards add autocorrect. Nothing to ship.
spellCheck
autoCorrect="on"
autoCapitalize="sentences"
onChange={(e) => {
setDraft(e.target.value);
onTyping?.();
}}
onKeyDown={onKeyDown}
/>
<button type="submit" className="miu-send" disabled={(!draft.trim() && !staged) || sending}>
Send
</button>
</div>
</form>
);
}
@@ -0,0 +1,53 @@
import type { Conversation } from '../types';
import { stripMarkup } from '../rich-text';
/** Initials for the avatar chip — first letters of the first two words. */
function initials(title: string): string {
const parts = title.trim().split(/\s+/).filter(Boolean);
const chars = (parts[0]?.[0] ?? '') + (parts[1]?.[0] ?? '');
return (chars || '?').toUpperCase();
}
/**
* Presentational conversation list. Owns no data fetching — the parent passes the
* conversations (from useConversations) so a host can also drive it from its own store.
*/
export function ConversationList({
conversations,
selectedId,
onSelect,
}: {
conversations: Conversation[];
selectedId?: string | null;
onSelect: (threadId: string) => void;
}) {
if (conversations.length === 0) {
return <div className="miu-empty">No conversations yet.</div>;
}
return (
<ul className="miu-convlist" role="list">
{conversations.map((c) => (
<li key={c.threadId}>
<button
type="button"
className={`miu-convrow${c.threadId === selectedId ? ' is-active' : ''}`}
onClick={() => onSelect(c.threadId)}
>
<span className="miu-avatar" aria-hidden="true">
{c.membership === 'channel' ? '#' : initials(c.title)}
</span>
<span className="miu-convrow-main">
<span className="miu-convrow-title">{c.title}</span>
{c.lastMessage ? <span className="miu-convrow-preview">{stripMarkup(c.lastMessage)}</span> : null}
</span>
{c.unread > 0 ? (
<span className="miu-badge" aria-label={`${c.unread} unread`}>
{c.unread}
</span>
) : null}
</button>
</li>
))}
</ul>
);
}
@@ -0,0 +1,97 @@
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MessagingProvider } from '../provider';
import { MockAdapter } from '../adapters/mock';
import { ConversationSettings } from './conversation-settings';
function mount(adapter = new MockAdapter()) {
render(
<MessagingProvider adapter={adapter}>
<ConversationSettings threadId="th_mock_2" title="Storm crew" membership="group" onClose={() => {}} />
</MessagingProvider>,
);
return adapter;
}
describe('MockAdapter member management', () => {
it('adds and removes a member, and renames', async () => {
const a = new MockAdapter();
await a.addMember('th_mock_2', 'pp_sofia');
expect((await a.listMembers('th_mock_2')).some((m) => m.id === 'pp_sofia')).toBe(true);
await a.removeMember('th_mock_2', 'pp_sofia');
expect((await a.listMembers('th_mock_2')).some((m) => m.id === 'pp_sofia')).toBe(false);
await a.renameConversation('th_mock_2', 'Renamed');
expect((await a.listConversations()).find((c) => c.threadId === 'th_mock_2')?.title).toBe('Renamed');
});
});
describe('<ConversationSettings />', () => {
it('lists members and adds one from the directory', async () => {
const a = mount();
// A current member is shown.
await screen.findByText('Dan Whitaker');
// Add an addable person from the directory.
const sofia = await screen.findByText('Sofia Ramirez');
fireEvent.click(sofia);
await waitFor(async () => {
expect((await a.listMembers('th_mock_2')).some((m) => m.id === 'pp_sofia')).toBe(true);
});
});
it('“#” switches the picker to channels you belong to, excluding this one', async () => {
mount();
await screen.findByText('Dan Whitaker');
fireEvent.change(screen.getByLabelText('Search people'), { target: { value: '#' } });
// Channels the mock user is in show up as import sources…
await screen.findByText('#general');
// …and the conversation being edited is never offered as its own source.
expect(screen.queryByText('#Storm crew')).toBeNull();
});
it('imports a channel roster only after confirmation, and reports what landed', async () => {
const a = mount();
await screen.findByText('Dan Whitaker');
const before = (await a.listMembers('th_mock_2')).length;
fireEvent.change(screen.getByLabelText('Search people'), { target: { value: '#' } });
fireEvent.click(await screen.findByText('#general'));
// Staged, NOT applied — picking a channel must not mutate membership on its own.
const confirm = await screen.findByRole('button', { name: /^Add \d+$/ });
expect((await a.listMembers('th_mock_2')).length).toBe(before);
fireEvent.click(confirm);
await waitFor(async () => {
expect((await a.listMembers('th_mock_2')).length).toBeGreaterThan(before);
});
await screen.findByText(/Added \d+/);
});
it('cancelling a staged import leaves membership untouched', async () => {
const a = mount();
await screen.findByText('Dan Whitaker');
const before = (await a.listMembers('th_mock_2')).length;
fireEvent.change(screen.getByLabelText('Search people'), { target: { value: '#' } });
fireEvent.click(await screen.findByText('#general'));
fireEvent.click(await screen.findByRole('button', { name: 'Cancel' }));
await screen.findByLabelText('Search people'); // back to the picker
expect((await a.listMembers('th_mock_2')).length).toBe(before);
});
it('hides the channel-import affordance when the adapter cannot bulk-add', async () => {
const a = new MockAdapter();
// A host that implements single add but not addMembers => no import path offered.
(a as { addMembers?: unknown }).addMembers = undefined;
render(
<MessagingProvider adapter={a}>
<ConversationSettings threadId="th_mock_2" title="Storm crew" membership="group" onClose={() => {}} />
</MessagingProvider>,
);
await screen.findByText('Dan Whitaker');
expect(screen.getByLabelText('Search people').getAttribute('placeholder')).toBe('Search people…');
fireEvent.change(screen.getByLabelText('Search people'), { target: { value: '#' } });
expect(screen.queryByText('#general')).toBeNull(); // '#' is just a search string here
});
});
@@ -0,0 +1,253 @@
import { useEffect, useMemo, useState } from 'react';
import { useAdapter } from '../provider';
import { useConversations } from '../hooks/use-conversations';
import { ModalPortal } from './modal-portal';
import type { BulkAddResult, Conversation, Person } from '../types';
/** A source channel the caller belongs to, staged for a roster import once its members are read. */
interface PendingImport {
source: Conversation;
/** Members of the source who are NOT already here — the ones an import would actually add. */
newcomers: Person[];
/** Members of the source already in this conversation; reported so the count is never surprising. */
alreadyHere: number;
}
/**
* Settings for a group or channel (public + private): rename, member list, add/remove people, and
* leave. Add/remove/rename appear only when the adapter implements them AND — for the server — OPA
* allows it (admin-only); the panel is optimistic and surfaces the error if the door refuses.
*/
export function ConversationSettings({
threadId,
title,
membership,
onClose,
onLeft,
}: {
threadId: string;
title: string;
membership: 'group' | 'channel';
onClose: () => void;
onLeft?: () => void;
}) {
const adapter = useAdapter();
const [members, setMembers] = useState<Person[]>([]);
const [directory, setDirectory] = useState<Person[]>([]);
const [name, setName] = useState(title);
const [q, setQ] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [nonce, setNonce] = useState(0);
const [pending, setPending] = useState<PendingImport | null>(null);
const [imported, setImported] = useState<BulkAddResult | null>(null);
const { conversations } = useConversations();
const canManage = typeof adapter.addMember === 'function' && typeof adapter.removeMember === 'function';
const canRename = typeof adapter.renameConversation === 'function';
const canLeave = membership === 'channel' && typeof adapter.leaveChannel === 'function';
// Importing a roster needs both halves: read the source's members, and bulk-add them here.
const canImport = typeof adapter.addMembers === 'function' && typeof adapter.listMembers === 'function';
// The channels you belong to are the only valid import sources — this list is already
// membership-scoped (it is your own conversation list), so private channels appear iff you're in
// them, and the server re-checks the source-membership rule on the roster read regardless.
const sourceChannels = useMemo(
() => conversations.filter((c) => c.membership === 'channel' && c.threadId !== threadId),
[conversations, threadId],
);
useEffect(() => {
let alive = true;
void Promise.all([
adapter.listMembers ? adapter.listMembers(threadId) : Promise.resolve<Person[]>([]),
adapter.directory ? adapter.directory() : Promise.resolve<Person[]>([]),
]).then(([m, d]) => {
if (alive) {
setMembers(m);
setDirectory(d);
}
});
return () => {
alive = false;
};
}, [adapter, threadId, nonce]);
const memberIds = useMemo(() => new Set(members.map((m) => m.id)), [members]);
// A leading '#' switches the picker from people to channels — the roster-import affordance.
const channelMode = canImport && q.trim().startsWith('#');
const channelQuery = q.trim().slice(1).toLowerCase();
const addable = directory.filter((p) => !memberIds.has(p.id) && p.name.toLowerCase().includes(q.trim().toLowerCase()));
const matchingChannels = sourceChannels.filter((c) => c.title.toLowerCase().includes(channelQuery));
/** Stage an import: read the source roster, then diff it against who is already here. */
async function stageImport(source: Conversation): Promise<void> {
setBusy(true);
setError(null);
try {
const roster = await adapter.listMembers!(source.threadId);
setPending({
source,
newcomers: roster.filter((p) => !memberIds.has(p.id)),
alreadyHere: roster.filter((p) => memberIds.has(p.id)).length,
});
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
}
/** Commit the staged import. One bulk call; the result reports what actually landed. */
async function confirmImport(): Promise<void> {
if (!pending) return;
const ids = pending.newcomers.map((p) => p.id);
await run(async () => {
const res = await adapter.addMembers!(threadId, ids);
setImported(res);
setPending(null);
setQ('');
});
}
async function run(fn: () => Promise<void>): Promise<void> {
setBusy(true);
setError(null);
try {
await fn();
setNonce((n) => n + 1);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
}
return (
<ModalPortal>
<div className="miu-modal-overlay" onMouseDown={onClose}>
<div className="miu-modal" role="dialog" aria-modal="true" onMouseDown={(e) => e.stopPropagation()}>
<div className="miu-modal-head">
<span>{membership === 'channel' ? 'Channel' : 'Group'} settings</span>
<button type="button" className="miu-pane-close" onClick={onClose} aria-label="Close"></button>
</div>
<div className="miu-modal-body">
{canRename ? (
<div className="miu-field">
<span className="miu-field-lbl">Name</span>
<div className="miu-composer-row">
<input className="miu-input" value={name} onChange={(e) => setName(e.target.value)} aria-label="Conversation name" />
<button
type="button"
className="miu-send"
disabled={busy || !name.trim() || name.trim() === title}
onClick={() => void run(() => adapter.renameConversation!(threadId, name.trim()))}
>
Rename
</button>
</div>
</div>
) : null}
<div className="miu-field">
<span className="miu-field-lbl">Members · {members.length}</span>
<div className="miu-settings-list">
{members.map((m) => (
<div key={m.id} className="miu-settings-member">
<span className="miu-settings-name">{m.name}</span>
<span className="miu-pill">{m.kind}</span>
{canManage ? (
<button type="button" className="miu-attach-x" title="Remove" disabled={busy} onClick={() => void run(() => adapter.removeMember!(threadId, m.id))}>
</button>
) : null}
</div>
))}
</div>
</div>
{canManage && pending ? (
// Confirm step — an import is an administrative action, so it never fires on a stray click.
<div className="miu-field">
<span className="miu-field-lbl">Add from #{pending.source.title}</span>
{pending.newcomers.length === 0 ? (
<div className="miu-empty">Everyone from #{pending.source.title} is already here.</div>
) : (
<div className="miu-empty">
Add {pending.newcomers.length} {pending.newcomers.length === 1 ? 'person' : 'people'} from #{pending.source.title}?
{pending.alreadyHere > 0 ? ` (${pending.alreadyHere} already here)` : ''}
</div>
)}
<div className="miu-composer-row">
<button type="button" className="miu-tab" disabled={busy} onClick={() => setPending(null)}>Cancel</button>
<button type="button" className="miu-send" disabled={busy || pending.newcomers.length === 0} onClick={() => void confirmImport()}>
Add {pending.newcomers.length > 0 ? pending.newcomers.length : ''}
</button>
</div>
</div>
) : null}
{canManage && !pending ? (
<div className="miu-field">
<span className="miu-field-lbl">Add people</span>
<input
className="miu-input"
value={q}
onChange={(e) => { setQ(e.target.value); setImported(null); }}
placeholder={canImport ? 'Search people… or # for a channel' : 'Search people…'}
aria-label="Search people"
/>
<div className="miu-settings-list">
{channelMode ? (
<>
{matchingChannels.length === 0 ? <div className="miu-empty">No channels to add from.</div> : null}
{matchingChannels.slice(0, 25).map((c) => (
<button key={c.threadId} type="button" className="miu-settings-member is-add" disabled={busy} onClick={() => void stageImport(c)}>
<span className="miu-settings-name">#{c.title}</span>
<span className="miu-pill">channel</span>
<span className="miu-settings-plus" aria-hidden="true"></span>
</button>
))}
</>
) : (
<>
{addable.length === 0 ? <div className="miu-empty">No one to add.</div> : null}
{addable.slice(0, 25).map((p) => (
<button key={p.id} type="button" className="miu-settings-member is-add" disabled={busy} onClick={() => void run(() => adapter.addMember!(threadId, p.id))}>
<span className="miu-settings-name">{p.name}</span>
<span className="miu-pill">{p.kind}</span>
<span className="miu-settings-plus" aria-hidden="true"></span>
</button>
))}
</>
)}
</div>
</div>
) : null}
{imported ? (
<div className="miu-empty">
Added {imported.added.length}
{imported.skipped.length > 0 ? ` · ${imported.skipped.length} already a member` : ''}
{imported.failed.length > 0 ? ` · ${imported.failed.length} failed` : ''}
</div>
) : null}
{error ? <div className="miu-empty miu-error">{error}</div> : null}
</div>
<div className="miu-modal-foot">
{canLeave ? (
<button type="button" className="miu-tab" disabled={busy} onClick={() => void run(async () => { await adapter.leaveChannel!(threadId); onLeft?.(); onClose(); })}>
Leave {membership}
</button>
) : (
<span />
)}
<button type="button" className="miu-send" onClick={onClose}>Done</button>
</div>
</div>
</div>
</ModalPortal>
);
}
@@ -0,0 +1,128 @@
import { useRef, useState } from 'react';
import { renderRichText } from '../rich-text';
import { PopoverPortal } from './popover-portal';
import type { Attachment } from '../types';
import type { UiMessage } from '../hooks/use-messages';
const REACTION_EMOJIS = ['👍', '❤️', '😂', '🎉', '👀'];
const isImage = (mime: string): boolean => mime.startsWith('image/');
/** Slack-style message time: today shows the clock, then "Yesterday", weekday, else a date. */
function messageTime(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(+d)) return '';
const now = new Date();
const time = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (d.toDateString() === now.toDateString()) return time;
const yesterday = new Date(now);
yesterday.setDate(now.getDate() - 1);
if (d.toDateString() === yesterday.toDateString()) return `Yesterday ${time}`;
if (now.getTime() - d.getTime() < 7 * 86400000) return `${d.toLocaleDateString([], { weekday: 'short' })} ${time}`;
return `${d.toLocaleDateString([], { month: 'short', day: 'numeric' })} ${time}`;
}
function AttachmentView({ att }: { att: Attachment }) {
if (isImage(att.mime)) {
return (
<a href={att.url} target="_blank" rel="noreferrer" className="miu-att-img-link">
<img src={att.url} alt={att.name} className="miu-att-img" />
</a>
);
}
return (
<a href={att.url} target="_blank" rel="noreferrer" className="miu-att-file">
📎 {att.name}
</a>
);
}
/** One rendered message: bubble (with @mention highlighting), attachment, reactions, seen tick,
* and — in the main thread only — a thread/reply affordance. */
export function MessageItem({
message,
memberNames,
canReact,
onReact,
seen,
replyCount,
onOpenThread,
}: {
message: UiMessage;
memberNames: string[];
canReact: boolean;
onReact: (messageId: string, emoji: string) => void;
seen: boolean;
/** Present only in the main thread (not inside the pane). undefined => no thread affordance. */
replyCount?: number;
onOpenThread?: (messageId: string) => void;
}) {
const [pickerOpen, setPickerOpen] = useState(false);
const reactBtnRef = useRef<HTMLButtonElement>(null);
const m = message;
return (
<div className={`miu-msg${message.mine ? ' is-mine' : ''}${message.pending ? ' is-pending' : ''}`}>
<div className="miu-bubble-row">
<div className="miu-bubble">
{m.text ? renderRichText(m.text, memberNames) : null}
{m.attachment ? <AttachmentView att={m.attachment} /> : null}
</div>
<time className="miu-msg-time" dateTime={m.at} title={Number.isNaN(+new Date(m.at)) ? '' : new Date(m.at).toLocaleString()}>
{messageTime(m.at)}
</time>
<div className="miu-msg-actions">
{canReact ? (
<div className="miu-react-wrap">
<button ref={reactBtnRef} type="button" className="miu-react-btn" title="React" onClick={() => setPickerOpen((p) => !p)}>
🙂
</button>
{pickerOpen ? (
<PopoverPortal anchorRef={reactBtnRef} onClose={() => setPickerOpen(false)}>
<div className="miu-react-picker">
{REACTION_EMOJIS.map((e) => (
<button
key={e}
type="button"
className="miu-react-emoji"
onClick={() => {
onReact(m.id, e);
setPickerOpen(false);
}}
>
{e}
</button>
))}
</div>
</PopoverPortal>
) : null}
</div>
) : null}
{onOpenThread ? (
<button type="button" className="miu-react-btn" title="Reply in thread" onClick={() => onOpenThread(m.id)}>
💬
</button>
) : null}
</div>
</div>
{m.reactions && m.reactions.length > 0 ? (
<div className="miu-reactions">
{m.reactions.map((r) => (
<button key={r.emoji} type="button" className={`miu-reaction${r.mine ? ' is-mine' : ''}`} onClick={() => canReact && onReact(m.id, r.emoji)}>
{r.emoji} {r.count}
</button>
))}
</div>
) : null}
{replyCount !== undefined && replyCount > 0 && onOpenThread ? (
<button type="button" className="miu-thread-link" onClick={() => onOpenThread(m.id)}>
💬 {replyCount} {replyCount === 1 ? 'reply' : 'replies'}
</button>
) : null}
{message.mine && seen ? <span className="miu-seen">Seen</span> : null}
</div>
);
}
@@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MessagingProvider } from '../provider';
import { Messenger } from './messenger';
import { MockAdapter } from '../adapters/mock';
function mount() {
return render(
<MessagingProvider adapter={new MockAdapter()}>
<Messenger />
</MessagingProvider>,
);
}
describe('<Messenger /> (rendered UI over an adapter)', () => {
it('renders the conversation list and auto-selects the first thread', async () => {
mount();
// Seeded group conversation title from the mock.
expect(await screen.findByText('Storm response — East side')).toBeTruthy();
// Auto-selected thread shows its seeded message.
expect(await screen.findByText('Crew is rolling out at 7.')).toBeTruthy();
});
it('sends a message through the adapter and shows it in the thread', async () => {
mount();
// Wait for the auto-selected thread's composer (avoids a race with the auto-select effect).
const input = (await screen.findByLabelText('Message')) as HTMLInputElement;
fireEvent.change(input, { target: { value: 'on our way' } });
fireEvent.click(screen.getByText('Send'));
await waitFor(() => expect(screen.getByText('on our way')).toBeTruthy());
// Composer cleared after send.
expect(input.value).toBe('');
});
it('switches threads when another conversation is clicked', async () => {
mount();
// Click the DM (its title is the other participant's name from the mock directory).
fireEvent.click(await screen.findByText('Sofia Ramirez'));
expect(await screen.findByText('Can you review the Henderson estimate?')).toBeTruthy();
});
});
@@ -0,0 +1,157 @@
import { useEffect, useMemo, useState } from 'react';
import { useConversations } from '../hooks/use-conversations';
import { useAdapter } from '../provider';
import { ConversationList } from './conversation-list';
import { ChannelBrowser } from './channel-browser';
import { NewConversation } from './new-conversation';
import { Thread } from './thread';
import { ThreadPane } from './thread-pane';
/**
* The drop-in messenger: sectioned conversation list (Channels / Direct messages) + open thread,
* with a channel browser when the adapter supports channels. Owns only selection + browse state;
* all data flows through the injected adapter via the hooks.
*/
export function Messenger({ focusThreadId }: { focusThreadId?: string | null } = {}) {
const { conversations, loading, error, refetch } = useConversations();
const adapter = useAdapter();
const channelsSupported = typeof adapter.browseChannels === 'function';
const directorySupported = typeof adapter.directory === 'function';
const [selected, setSelected] = useState<string | null>(null);
const [browsing, setBrowsing] = useState(false);
const [composing, setComposing] = useState(false); // "New message" people picker open
const [activeRoot, setActiveRoot] = useState<string | null>(null); // open thread pane's root message
useEffect(() => {
if (browsing || composing) return;
if (selected && conversations.some((c) => c.threadId === selected)) return;
setSelected(conversations[0]?.threadId ?? null);
}, [conversations, selected, browsing, composing]);
// Switching conversations (or into browse/compose) closes any open thread pane.
useEffect(() => setActiveRoot(null), [selected, browsing, composing]);
// Deep link (e.g. from global search): focus the requested conversation.
useEffect(() => {
if (!focusThreadId) return;
setBrowsing(false);
setComposing(false);
setSelected(focusThreadId);
}, [focusThreadId]);
// Presence: tell the backend which thread is foregrounded so it suppresses push for it. Null while
// browsing/composing, when the window is blurred, and on unmount (leaving the messenger).
useEffect(() => {
const active = browsing || composing ? null : selected;
adapter.setFocus?.(active);
const onBlur = (): void => adapter.setFocus?.(null);
const onFocus = (): void => adapter.setFocus?.(active);
window.addEventListener('blur', onBlur);
window.addEventListener('focus', onFocus);
return () => {
adapter.setFocus?.(null);
window.removeEventListener('blur', onBlur);
window.removeEventListener('focus', onFocus);
};
}, [adapter, selected, browsing, composing]);
// Live unread + ordering: refresh the conversation list when any of the caller's threads gets a
// message (not just the open one).
useEffect(() => {
if (!adapter.subscribeActivity) return;
return adapter.subscribeActivity(() => refetch());
}, [adapter, refetch]);
const channels = useMemo(() => conversations.filter((c) => c.membership === 'channel'), [conversations]);
const dms = useMemo(() => conversations.filter((c) => c.membership !== 'channel'), [conversations]);
const selectedConversation = useMemo(() => conversations.find((c) => c.threadId === selected) ?? null, [conversations, selected]);
function pick(threadId: string): void {
setBrowsing(false);
setComposing(false);
setSelected(threadId);
}
function openBrowse(): void {
setComposing(false);
setBrowsing(true);
}
function openCompose(): void {
setBrowsing(false);
setComposing(true);
}
return (
<div className="miu-messenger">
<aside className="miu-sidebar">
{loading && conversations.length === 0 ? <div className="miu-empty">Loading</div> : null}
{error ? <div className="miu-empty miu-error">{error}</div> : null}
{channelsSupported ? (
<div className="miu-section">
<div className="miu-section-head">
<span>Channels</span>
<button type="button" className="miu-section-add" title="Browse channels" onClick={openBrowse}>
</button>
</div>
{channels.length > 0 ? (
<ConversationList conversations={channels} selectedId={browsing || composing ? null : selected} onSelect={pick} />
) : (
<div className="miu-empty miu-empty-sm">Browse to join a channel.</div>
)}
</div>
) : null}
<div className="miu-section">
{channelsSupported || directorySupported ? (
<div className="miu-section-head">
<span>Direct messages</span>
{directorySupported ? (
<button type="button" className="miu-section-add" title="New message" onClick={openCompose}>
</button>
) : null}
</div>
) : null}
<ConversationList conversations={dms} selectedId={browsing || composing ? null : selected} onSelect={pick} />
</div>
</aside>
<section className="miu-main">
{browsing ? (
<ChannelBrowser
onJoined={(threadId) => {
refetch();
pick(threadId);
}}
/>
) : composing ? (
<NewConversation
onCreated={(threadId) => {
refetch();
pick(threadId);
}}
/>
) : (
<Thread
threadId={selected}
conversation={selectedConversation}
activeRootId={activeRoot}
onOpenThread={setActiveRoot}
onLeft={() => {
refetch();
setSelected(null);
}}
/>
)}
</section>
{!browsing && !composing && selected && activeRoot ? (
<ThreadPane threadId={selected} rootId={activeRoot} onClose={() => setActiveRoot(null)} />
) : null}
</div>
);
}
@@ -0,0 +1,35 @@
import { useState, type ReactNode, type CSSProperties } from 'react';
import { createPortal } from 'react-dom';
// The SDK theme tokens. A body-portaled node is outside the `.miu-messenger`/`.miu-inbox`
// subtree that defines these, so we copy their resolved values onto the portal root.
const THEME_VARS = [
'--miu-bg', '--miu-panel', '--miu-panel-2', '--miu-border',
'--miu-text', '--miu-muted', '--miu-accent', '--miu-accent-text', '--miu-radius',
] as const;
export function copyThemeVars(): Record<string, string> {
if (typeof document === 'undefined') return {};
const src = document.querySelector('.miu-messenger, .miu-inbox');
if (!src) return {};
const cs = getComputedStyle(src);
const out: Record<string, string> = {};
for (const v of THEME_VARS) {
const val = cs.getPropertyValue(v).trim();
if (val) out[v] = val;
}
return out;
}
/**
* Render an overlay into document.body so it escapes the host's stacking context and overflow.
* A host wrapper like `.view { position: relative; z-index: 1 }` traps a `position: fixed` overlay
* BELOW a sibling sticky header no matter how high its z-index — the only robust fix is to leave
* that stacking context entirely. Theme tokens are copied from the live surface (captured once on
* mount, synchronously, so there is no unstyled first paint) and re-applied on the portal root.
*/
export function ModalPortal({ children }: { children: ReactNode }) {
const [vars] = useState<Record<string, string>>(copyThemeVars);
if (typeof document === 'undefined') return null;
return createPortal(<div className="miu-portal" style={vars as CSSProperties}>{children}</div>, document.body);
}
@@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MessagingProvider } from '../provider';
import { MockAdapter } from '../adapters/mock';
import { Messenger } from './messenger';
function mount(adapter = new MockAdapter()) {
render(
<MessagingProvider adapter={adapter}>
<Messenger />
</MessagingProvider>,
);
return adapter;
}
describe('MockAdapter directory + openThread', () => {
it('directory lists people you can message', async () => {
const a = new MockAdapter();
const people = await a.directory();
expect(people.some((p) => p.name === 'Dan Whitaker')).toBe(true);
});
it('one participant opens a dm; two+ open a group with a subject', async () => {
const a = new MockAdapter();
const dm = await a.openThread({ participantIds: ['pp_dan'], membership: 'dm' });
const list = await a.listConversations();
expect(list.find((c) => c.threadId === dm.threadId)?.membership).toBe('dm');
const group = await a.openThread({ participantIds: ['pp_dan', 'pp_priya'], membership: 'group', subject: 'Roof crew' });
const g = (await a.listConversations()).find((c) => c.threadId === group.threadId);
expect(g?.membership).toBe('group');
expect(g?.title).toBe('Roof crew');
});
it('opening a dm with the same person reuses the existing thread', async () => {
const a = new MockAdapter();
const first = await a.openThread({ participantIds: ['pp_priya'], membership: 'dm' });
const second = await a.openThread({ participantIds: ['pp_priya'], membership: 'dm' });
expect(second.threadId).toBe(first.threadId);
});
});
describe('<Messenger /> new conversation flow', () => {
it('opens the picker from the Direct messages +, and starting a chat leaves the picker', async () => {
mount();
// Open the "New message" picker.
fireEvent.click(await screen.findByTitle('New message'));
expect(await screen.findByText('New message')).toBeTruthy();
// Pick a person and start a DM.
fireEvent.click(await screen.findByText('Dan Whitaker'));
fireEvent.click(screen.getByText('Start chat'));
// The picker closes (we're back in a thread view — the picker heading is gone).
await waitFor(() => expect(screen.queryByText('Start chat')).toBeNull());
});
it('selecting two people switches the action to group create', async () => {
mount();
fireEvent.click(await screen.findByTitle('New message'));
fireEvent.click(await screen.findByText('Dan Whitaker'));
fireEvent.click(await screen.findByText('Priya Nair'));
expect(screen.getByText(/Create group \(2\)/)).toBeTruthy();
expect(screen.getByLabelText('Group name')).toBeTruthy();
});
});
@@ -0,0 +1,97 @@
import { useEffect, useState } from 'react';
import { useAdapter } from '../provider';
import type { Person } from '../types';
/**
* Start a direct message or a group — Slack-style. Pick people from the org directory: one selected
* opens a DM (deduped by the adapter), two or more create a group with an optional name. Shown in
* the main pane like the channel browser.
*/
export function NewConversation({ onCreated }: { onCreated?: (threadId: string) => void }) {
const adapter = useAdapter();
const [people, setPeople] = useState<Person[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [q, setQ] = useState('');
const [selected, setSelected] = useState<string[]>([]);
const [name, setName] = useState('');
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!adapter.directory) {
setLoading(false);
return;
}
let alive = true;
adapter
.directory()
.then((p) => {
if (alive) {
setPeople(p);
setError(null);
}
})
.catch((e: unknown) => {
if (alive) setError(e instanceof Error ? e.message : String(e));
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, [adapter]);
const filtered = people.filter((p) => p.name.toLowerCase().includes(q.trim().toLowerCase()));
const isGroup = selected.length > 1;
const canStart = selected.length >= 1 && !busy;
function toggle(id: string): void {
setSelected((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]));
}
async function start(): Promise<void> {
if (!canStart) return;
setBusy(true);
setError(null);
try {
const res = await adapter.openThread({
participantIds: selected,
membership: isGroup ? 'group' : 'dm',
...(isGroup && name.trim() ? { subject: name.trim() } : {}),
});
onCreated?.(res.threadId);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
}
return (
<div className="miu-browser">
<div className="miu-browser-head">New message</div>
<div className="miu-newconv">
<input className="miu-input" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search people…" aria-label="Search people" />
{isGroup ? (
<input className="miu-input" value={name} onChange={(e) => setName(e.target.value)} placeholder="Group name (optional)" aria-label="Group name" />
) : null}
{error ? <div className="miu-empty miu-error">{error}</div> : null}
<div className="miu-browser-list">
{loading && people.length === 0 ? <div className="miu-empty">Loading</div> : null}
{!loading && filtered.length === 0 ? <div className="miu-empty">No people found.</div> : null}
{filtered.map((p) => (
<label key={p.id} className={`miu-person-row${selected.includes(p.id) ? ' is-active' : ''}`}>
<input type="checkbox" checked={selected.includes(p.id)} onChange={() => toggle(p.id)} />
<span className="miu-browser-name">{p.name}</span>
<span className="miu-pill">{p.kind}</span>
</label>
))}
</div>
<button type="button" className="miu-send" onClick={() => void start()} disabled={!canStart}>
{busy ? 'Starting…' : isGroup ? `Create group (${selected.length})` : 'Start chat'}
</button>
</div>
</div>
);
}
@@ -0,0 +1,67 @@
import { useEffect, useLayoutEffect, useState, type CSSProperties, type ReactNode, type RefObject } from 'react';
import { createPortal } from 'react-dom';
import { copyThemeVars } from './modal-portal';
/**
* A small popover (e.g. the reaction picker) rendered into document.body so it is never clipped by
* a scroll container's `overflow: hidden` — the reported "emoji picker goes beneath the container"
* bug. Positioned fixed just below the anchor, right-aligned to it, and re-placed on scroll/resize.
* Closes on outside pointer-down, scroll of a different element, or Escape. Carries the SDK theme
* tokens (copied from the live surface) since a body-portaled node is outside the themed subtree.
*/
export function PopoverPortal({
anchorRef,
onClose,
children,
}: {
anchorRef: RefObject<HTMLElement | null>;
onClose: () => void;
children: ReactNode;
}) {
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
const [vars] = useState(copyThemeVars);
useLayoutEffect(() => {
const el = anchorRef.current;
if (!el) return;
const place = (): void => {
const r = el.getBoundingClientRect();
setPos({ top: r.bottom + 4, left: r.right });
};
place();
window.addEventListener('resize', place);
window.addEventListener('scroll', place, true);
return () => {
window.removeEventListener('resize', place);
window.removeEventListener('scroll', place, true);
};
}, [anchorRef]);
useEffect(() => {
const onDown = (e: PointerEvent): void => {
const target = e.target as Node;
if (anchorRef.current?.contains(target)) return;
if ((target as Element).closest?.('.miu-popover')) return;
onClose();
};
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('pointerdown', onDown, true);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('pointerdown', onDown, true);
document.removeEventListener('keydown', onKey);
};
}, [anchorRef, onClose]);
if (typeof document === 'undefined' || !pos) return null;
return createPortal(
<div className="miu-portal">
<div className="miu-popover" style={{ top: pos.top, left: pos.left, ...vars } as CSSProperties}>
{children}
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MessagingProvider } from '../provider';
import { Messenger } from './messenger';
import { MockAdapter } from '../adapters/mock';
function mount() {
return render(
<MessagingProvider adapter={new MockAdapter()}>
<Messenger />
</MessagingProvider>,
);
}
describe('Slack-style thread pane', () => {
it('opens a thread on 💬, posts a reply into it, and shows the reply count on the parent', async () => {
mount();
// Wait for the auto-selected thread's message to render WITH its actions (not just the sidebar
// preview), then open the thread pane via the message's reply affordance (💬).
const replyButtons = await screen.findAllByTitle('Reply in thread');
fireEvent.click(replyButtons[0]!);
expect(await screen.findByText('Thread')).toBeTruthy(); // pane header
// The pane's composer (placeholder "Reply…") — send a threaded reply.
const replyInput = screen.getByPlaceholderText('Reply…') as HTMLInputElement;
fireEvent.change(replyInput, { target: { value: 'on it' } });
// The pane has its own Send; grab the last one (pane is rendered after the main composer).
const sends = screen.getAllByText('Send');
fireEvent.click(sends[sends.length - 1]!);
// The reply shows in the pane (replies are hidden from the main thread, so this is unique)...
await waitFor(() => expect(screen.getByText('on it')).toBeTruthy());
// ...and the parent now advertises the reply count as a thread-link button in the main thread.
await waitFor(() => expect(screen.getByRole('button', { name: /1 reply/ })).toBeTruthy());
});
});
@@ -0,0 +1,60 @@
import { useMemo } from 'react';
import { useMessages } from '../hooks/use-messages';
import { useMembers } from '../hooks/use-members';
import { Composer } from './composer';
import { MessageItem } from './message-item';
/**
* The Slack-style thread side panel: the root message + its replies + a composer that posts back
* into the thread (parentInteractionId = root). Uses its own useMessages on the same thread; the
* shared adapter subscription keeps it and the main view in sync.
*/
export function ThreadPane({
threadId,
rootId,
onClose,
}: {
threadId: string;
rootId: string;
onClose: () => void;
}) {
const { messages, send, react, upload, seenIds, sendTyping, canReact, canUpload } = useMessages(threadId);
const members = useMembers(threadId);
const memberNames = useMemo(() => members.map((m) => m.name), [members]);
const root = useMemo(() => messages.find((m) => m.id === rootId), [messages, rootId]);
const replies = useMemo(() => messages.filter((m) => m.parentInteractionId === rootId), [messages, rootId]);
return (
<aside className="miu-pane">
<header className="miu-pane-head">
<span>Thread</span>
<button type="button" className="miu-pane-close" onClick={onClose} aria-label="Close thread">
</button>
</header>
<div className="miu-messages miu-pane-messages">
{root ? (
<MessageItem message={root} memberNames={memberNames} canReact={canReact} onReact={(id, e) => void react(id, e)} seen={seenIds.has(root.id)} />
) : (
<div className="miu-empty">Message not found.</div>
)}
<div className="miu-pane-divider">{replies.length} {replies.length === 1 ? 'reply' : 'replies'}</div>
{replies.map((m) => (
<MessageItem key={m.id} message={m} memberNames={memberNames} canReact={canReact} onReact={(id, e) => void react(id, e)} seen={seenIds.has(m.id)} />
))}
</div>
<Composer
members={members}
canUpload={canUpload}
upload={upload}
onSend={send}
onTyping={sendTyping}
parentInteractionId={rootId}
placeholder="Reply…"
/>
</aside>
);
}
@@ -0,0 +1,94 @@
import { useMemo, useState } from 'react';
import { useMessages } from '../hooks/use-messages';
import { useMembers } from '../hooks/use-members';
import { Composer } from './composer';
import { MessageItem } from './message-item';
import { ConversationSettings } from './conversation-settings';
import type { Conversation } from '../types';
/**
* The main conversation view: top-level messages + composer. Replies (messages with a
* parentInteractionId) are hidden here and live in the ThreadPane — a message with replies shows a
* "N replies" link that opens it. @mentions, reactions, and attachments all work.
*/
export function Thread({
threadId,
conversation,
activeRootId,
onOpenThread,
onLeft,
}: {
threadId: string | null;
conversation?: Conversation | null;
activeRootId?: string | null;
onOpenThread?: (rootId: string) => void;
onLeft?: () => void;
}) {
const { messages, loading, error, send, react, upload, typingUserIds, seenIds, sendTyping, canReact, canUpload } = useMessages(threadId);
const members = useMembers(threadId);
const memberNames = useMemo(() => members.map((m) => m.name), [members]);
const [settingsOpen, setSettingsOpen] = useState(false);
const membership = conversation?.membership;
const manageable = membership === 'group' || membership === 'channel';
const topLevel = useMemo(() => messages.filter((m) => !m.parentInteractionId), [messages]);
const replyCount = useMemo(() => {
const counts = new Map<string, number>();
for (const m of messages) if (m.parentInteractionId) counts.set(m.parentInteractionId, (counts.get(m.parentInteractionId) ?? 0) + 1);
return counts;
}, [messages]);
if (!threadId) {
return <div className="miu-empty miu-thread-empty">Select a conversation.</div>;
}
return (
<div className={`miu-thread${activeRootId ? ' has-pane' : ''}`}>
{conversation ? (
<div className="miu-thread-head">
<span className="miu-thread-title">
{membership === 'channel' ? '# ' : ''}
{conversation.title || 'Conversation'}
</span>
{manageable ? (
<button type="button" className="miu-thread-settings" title="Settings" aria-label="Conversation settings" onClick={() => setSettingsOpen(true)}>
</button>
) : null}
</div>
) : null}
<div className="miu-messages">
{loading && messages.length === 0 ? <div className="miu-empty">Loading</div> : null}
{error ? <div className="miu-empty miu-error">{error}</div> : null}
{topLevel.map((m) => (
<MessageItem
key={m.id}
message={m}
memberNames={memberNames}
canReact={canReact}
onReact={(id, emoji) => void react(id, emoji)}
seen={seenIds.has(m.id)}
replyCount={replyCount.get(m.id) ?? 0}
{...(onOpenThread ? { onOpenThread } : {})}
/>
))}
{typingUserIds.length > 0 ? (
<div className="miu-typing">{typingUserIds.length === 1 ? 'typing…' : 'several people are typing…'}</div>
) : null}
</div>
<Composer members={members} canUpload={canUpload} upload={upload} onSend={send} onTyping={sendTyping} />
{settingsOpen && manageable && conversation ? (
<ConversationSettings
threadId={conversation.threadId}
title={conversation.title || ''}
membership={membership as 'group' | 'channel'}
onClose={() => setSettingsOpen(false)}
{...(onLeft ? { onLeft } : {})}
/>
) : null}
</div>
);
}
@@ -0,0 +1,8 @@
import { runAdapterConformance } from './conformance';
import { MockAdapter } from './adapters/mock';
runAdapterConformance({
makeAdapter: () => new MockAdapter(),
seededThreadId: 'th_mock_1',
openWith: ['pp_sofia'],
});
@@ -0,0 +1,112 @@
import { describe, it, expect } from 'vitest';
import type { MessagingAdapter } from './adapter';
import type { MessageEvent } from './types';
export interface ConformanceOptions {
/** Build a fresh, isolated adapter per test. */
makeAdapter: () => Promise<MessagingAdapter> | MessagingAdapter;
/** A thread id that exists in the fixture. */
seededThreadId: string;
/** Participant ids openThread can legally be called with. */
openWith: string[];
}
/**
* The definition of a correct MessagingAdapter. Every adapter runs this.
*
* Imports vitest, so it ships from the './conformance' subpath ONLY and is never
* reachable from the main barrel. Consumers supply their own vitest.
*
* Usage from another package:
* import { runAdapterConformance } from '@insignia/iios-messaging-ui/conformance';
* runAdapterConformance({ makeAdapter: () => new MockAdapter(), seededThreadId: 'th_1', openWith: ['pp_a'] });
*/
export function runAdapterConformance(opts: ConformanceOptions): void {
const make = async () => await opts.makeAdapter();
describe('MessagingAdapter conformance', () => {
it('lists conversations', async () => {
const a = await make();
const list = await a.listConversations();
expect(Array.isArray(list)).toBe(true);
});
it('returns history for a seeded thread', async () => {
const a = await make();
const msgs = await a.history(opts.seededThreadId);
expect(Array.isArray(msgs)).toBe(true);
});
it('send resolves with a message carrying the sent text and a stable id', async () => {
const a = await make();
const m = await a.send(opts.seededThreadId, 'conformance hello');
expect(m.text).toBe('conformance hello');
expect(typeof m.id).toBe('string');
expect(m.id.length).toBeGreaterThan(0);
});
it('a sent message is attributed to the current actor', async () => {
const a = await make();
const m = await a.send(opts.seededThreadId, 'whose is this');
expect(m.actorId).toBe(a.currentActorId());
});
it('currentActorId is known BEFORE any message is sent', async () => {
// The bug this SDK exists to kill: identity must come from auth, never be
// inferred from history. A fresh adapter already knows who you are.
const a = await make();
expect(a.currentActorId()).not.toBeNull();
});
it('a sent message appears in history', async () => {
const a = await make();
await a.send(opts.seededThreadId, 'persist me');
const msgs = await a.history(opts.seededThreadId);
expect(msgs.some((m) => m.text === 'persist me')).toBe(true);
});
it('subscribe delivers a message event on send', async () => {
const a = await make();
const seen: MessageEvent[] = [];
const off = a.subscribe(opts.seededThreadId, (e) => seen.push(e));
await a.send(opts.seededThreadId, 'live one');
off();
const msgs = seen.filter((e) => e.kind === 'message');
expect(msgs.length).toBeGreaterThan(0);
});
it('unsubscribe stops delivery', async () => {
const a = await make();
const seen: MessageEvent[] = [];
const off = a.subscribe(opts.seededThreadId, (e) => seen.push(e));
off();
await a.send(opts.seededThreadId, 'should not be heard');
expect(seen).toHaveLength(0);
});
it('unsubscribe is idempotent', async () => {
const a = await make();
const off = a.subscribe(opts.seededThreadId, () => {});
off();
expect(() => off()).not.toThrow();
});
it('openThread returns a thread id', async () => {
const a = await make();
const { threadId } = await a.openThread({ participantIds: opts.openWith });
expect(typeof threadId).toBe('string');
expect(threadId.length).toBeGreaterThan(0);
});
it('markRead resolves', async () => {
const a = await make();
const m = await a.send(opts.seededThreadId, 'read me');
await expect(a.markRead(opts.seededThreadId, m.id)).resolves.toBeUndefined();
});
it('sendTyping does not throw', async () => {
const a = await make();
expect(() => a.sendTyping(opts.seededThreadId)).not.toThrow();
});
});
}
@@ -0,0 +1,84 @@
import { useCallback, useEffect, useState } from 'react';
import { useAdapter } from '../provider';
import type { ChannelSummary, CreateChannelInput } from '../types';
export interface ChannelsState {
/** Discoverable channels (public + private-I'm-in), each flagged `joined`. */
browsable: ChannelSummary[];
loading: boolean;
error: string | null;
/** Whether the adapter implements channels at all — drives showing/hiding the channels UI. */
supported: boolean;
refetch: () => void;
create: (input: CreateChannelInput) => Promise<string>;
join: (threadId: string) => Promise<void>;
leave: (threadId: string) => Promise<void>;
}
export function useChannels(): ChannelsState {
const adapter = useAdapter();
const supported = typeof adapter.browseChannels === 'function';
const [browsable, setBrowsable] = useState<ChannelSummary[]>([]);
const [loading, setLoading] = useState(supported);
const [error, setError] = useState<string | null>(null);
const [nonce, setNonce] = useState(0);
useEffect(() => {
if (!adapter.browseChannels) {
setLoading(false);
return;
}
let alive = true;
setLoading(true);
adapter
.browseChannels()
.then((list) => {
if (!alive) return;
setBrowsable(list);
setError(null);
})
.catch((e: unknown) => {
if (!alive) return;
setError(e instanceof Error ? e.message : String(e));
setBrowsable([]);
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, [adapter, nonce]);
const refetch = useCallback(() => setNonce((n) => n + 1), []);
const create = useCallback(
async (input: CreateChannelInput) => {
if (!adapter.createChannel) throw new Error('channels not supported by this adapter');
const { threadId } = await adapter.createChannel(input);
setNonce((n) => n + 1);
return threadId;
},
[adapter],
);
const join = useCallback(
async (threadId: string) => {
if (!adapter.joinChannel) throw new Error('channels not supported by this adapter');
await adapter.joinChannel(threadId);
setNonce((n) => n + 1);
},
[adapter],
);
const leave = useCallback(
async (threadId: string) => {
if (!adapter.leaveChannel) throw new Error('channels not supported by this adapter');
await adapter.leaveChannel(threadId);
setNonce((n) => n + 1);
},
[adapter],
);
return { browsable, loading, error, supported, refetch, create, join, leave };
}
@@ -0,0 +1,50 @@
import { describe, it, expect, vi } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
import type { ReactNode } from 'react';
import { MessagingProvider } from '../provider';
import { MockAdapter } from '../adapters/mock';
import { useConversations } from './use-conversations';
import type { MessagingAdapter } from '../adapter';
const wrap = (adapter: MessagingAdapter) =>
function Wrapper({ children }: { children: ReactNode }) {
return <MessagingProvider adapter={adapter}>{children}</MessagingProvider>;
};
describe('useConversations', () => {
it('starts loading, then resolves the adapter list', async () => {
const { result } = renderHook(() => useConversations(), { wrapper: wrap(new MockAdapter()) });
expect(result.current.loading).toBe(true);
await waitFor(() => expect(result.current.loading).toBe(false));
// 2 DMs/groups + 2 channels I'm a member of (the un-joined public channel is browse-only).
expect(result.current.conversations).toHaveLength(4);
expect(result.current.conversations[0]!.threadId).toBe('th_mock_1');
expect(result.current.error).toBeNull();
});
it('surfaces adapter failure as error state and never throws', async () => {
const adapter = new MockAdapter();
vi.spyOn(adapter, 'listConversations').mockRejectedValue(new Error('data door down'));
const { result } = renderHook(() => useConversations(), { wrapper: wrap(adapter) });
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBe('data door down');
expect(result.current.conversations).toEqual([]);
});
it('refetch picks up newly opened threads', async () => {
const adapter = new MockAdapter();
const { result } = renderHook(() => useConversations(), { wrapper: wrap(adapter) });
await waitFor(() => expect(result.current.conversations).toHaveLength(4));
await act(async () => {
await adapter.openThread({ participantIds: ['pp_dan'] });
});
await act(async () => {
result.current.refetch();
});
await waitFor(() => expect(result.current.conversations).toHaveLength(5));
});
});
@@ -0,0 +1,45 @@
import { useCallback, useEffect, useState } from 'react';
import { useAdapter } from '../provider';
import type { Conversation } from '../types';
export interface ConversationsState {
conversations: Conversation[];
loading: boolean;
error: string | null;
refetch: () => void;
}
export function useConversations(): ConversationsState {
const adapter = useAdapter();
const [conversations, setConversations] = useState<Conversation[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [nonce, setNonce] = useState(0);
useEffect(() => {
let alive = true;
setLoading(true);
adapter
.listConversations()
.then((list) => {
if (!alive) return;
setConversations(list);
setError(null);
})
.catch((e: unknown) => {
if (!alive) return;
setError(e instanceof Error ? e.message : String(e));
setConversations([]);
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, [adapter, nonce]);
const refetch = useCallback(() => setNonce((n) => n + 1), []);
return { conversations, loading, error, refetch };
}
@@ -0,0 +1,31 @@
import { useEffect, useState } from 'react';
import { useAdapter } from '../provider';
import type { Person } from '../types';
/** A thread's members (for @mention autocomplete + highlighting). Empty if the adapter
* doesn't implement listMembers, or while loading. */
export function useMembers(threadId: string | null): Person[] {
const adapter = useAdapter();
const [members, setMembers] = useState<Person[]>([]);
useEffect(() => {
if (!threadId || !adapter.listMembers) {
setMembers([]);
return;
}
let alive = true;
adapter
.listMembers(threadId)
.then((m) => {
if (alive) setMembers(m);
})
.catch(() => {
if (alive) setMembers([]);
});
return () => {
alive = false;
};
}, [adapter, threadId]);
return members;
}
@@ -0,0 +1,196 @@
import { describe, it, expect, vi } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
import type { ReactNode } from 'react';
import { MessagingProvider } from '../provider';
import { MockAdapter } from '../adapters/mock';
import { useMessages } from './use-messages';
import type { MessagingAdapter } from '../adapter';
import type { Message } from '../types';
const wrap = (adapter: MessagingAdapter) =>
function Wrapper({ children }: { children: ReactNode }) {
return <MessagingProvider adapter={adapter}>{children}</MessagingProvider>;
};
describe('useMessages', () => {
it('loads history for the thread', async () => {
const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(new MockAdapter()) });
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0]!.text).toBe('Can you review the Henderson estimate?');
});
// REGRESSION: the CRM inferred actor identity by scanning for a sent message, so
// before you had spoken in a thread EVERY message rendered as not-yours.
it('marks ownership correctly before the user has sent anything', async () => {
const adapter = new MockAdapter();
await adapter.send('th_mock_1', 'an earlier message of mine');
const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
await waitFor(() => expect(result.current.messages).toHaveLength(2));
// Never sent anything via the hook — ownership still resolves from currentActorId().
expect(result.current.messages[0]!.mine).toBe(false); // from pp_sofia
expect(result.current.messages[1]!.mine).toBe(true); // from me
});
it('appends an optimistic message immediately on send', async () => {
const adapter = new MockAdapter();
let release!: () => void;
vi.spyOn(adapter, 'send').mockImplementation(
() => new Promise((res) => { release = () => res({ id: 'srv_1', actorId: 'me', text: 'hi', at: '2026-07-17T10:00:00.000Z' }); }),
);
const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
await waitFor(() => expect(result.current.loading).toBe(false));
act(() => { void result.current.send('hi'); });
await waitFor(() => expect(result.current.messages).toHaveLength(2));
expect(result.current.messages[1]!.pending).toBe(true);
expect(result.current.messages[1]!.mine).toBe(true);
await act(async () => { release(); });
await waitFor(() => expect(result.current.messages[1]!.pending).toBeFalsy());
});
it('rolls back the optimistic message and reports error when send fails', async () => {
const adapter = new MockAdapter();
vi.spyOn(adapter, 'send').mockRejectedValue(new Error('offline'));
const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
await expect(result.current.send('doomed')).rejects.toThrow('offline');
});
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages.some((m) => m.text === 'doomed')).toBe(false);
expect(result.current.error).toBe('offline');
});
it('does not duplicate a message when the transport echoes it back', async () => {
const adapter = new MockAdapter();
const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
await waitFor(() => expect(result.current.loading).toBe(false));
// MockAdapter.send emits a 'message' event AND resolves with the same message.
await act(async () => { await result.current.send('echo once'); });
expect(result.current.messages.filter((m) => m.text === 'echo once')).toHaveLength(1);
});
it('collects typing user ids from subscribe events', async () => {
const adapter = new MockAdapter();
let emit!: (userId: string) => void;
vi.spyOn(adapter, 'subscribe').mockImplementation((_t, cb) => {
emit = (userId) => cb({ kind: 'typing', userId });
return () => {};
});
const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
await waitFor(() => expect(result.current.loading).toBe(false));
act(() => emit('pp_sofia'));
expect(result.current.typingUserIds).toEqual(['pp_sofia']);
});
it('unsubscribes on unmount', async () => {
const adapter = new MockAdapter();
const off = vi.fn();
vi.spyOn(adapter, 'subscribe').mockReturnValue(off);
const { unmount, result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
await waitFor(() => expect(result.current.loading).toBe(false));
unmount();
expect(off).toHaveBeenCalled();
});
it('keeps a live message that arrives before history resolves', async () => {
const adapter = new MockAdapter();
let resolveHistory!: (msgs: Message[]) => void;
vi.spyOn(adapter, 'history').mockImplementation(
() => new Promise<Message[]>((res) => { resolveHistory = res; }),
);
let emit!: (m: Message) => void;
vi.spyOn(adapter, 'subscribe').mockImplementation((_t, cb) => {
emit = (m) => cb({ kind: 'message', message: m });
return () => {};
});
const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
// A live message arrives while history() is still pending.
act(() => emit({ id: 'live_1', actorId: 'pp_sofia', text: 'ping before history', at: '2026-07-17T10:00:00.000Z' }));
// History resolves afterwards with an older message.
await act(async () => {
resolveHistory([{ id: 'hist_1', actorId: 'pp_sofia', text: 'older', at: '2026-07-17T09:00:00.000Z' }]);
});
const texts = result.current.messages.map((m) => m.text);
expect(texts).toContain('older');
expect(texts).toContain('ping before history'); // must NOT be clobbered by history load
});
it('clears a typing indicator after its TTL elapses', async () => {
vi.useFakeTimers();
try {
const adapter = new MockAdapter();
let emit!: (userId: string) => void;
vi.spyOn(adapter, 'subscribe').mockImplementation((_t, cb) => {
emit = (userId) => cb({ kind: 'typing', userId });
return () => {};
});
const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
await act(async () => { await vi.advanceTimersByTimeAsync(0); }); // flush history microtask
act(() => emit('pp_sofia'));
expect(result.current.typingUserIds).toEqual(['pp_sofia']);
await act(async () => { await vi.advanceTimersByTimeAsync(3600); });
expect(result.current.typingUserIds).toEqual([]);
} finally {
vi.useRealTimers();
}
});
});
describe('read receipts', () => {
// REGRESSION: the hook reported a read of the newest message regardless of author, so sending a
// message made the server echo a receipt for it and the sender's own bubble showed "Seen"
// immediately — in DMs, groups and channels alike, before anyone had opened it.
it('never reports a read of my own message', async () => {
const adapter = new MockAdapter();
const markRead = vi.spyOn(adapter, 'markRead');
const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
await waitFor(() => expect(result.current.loading).toBe(false));
markRead.mockClear();
await act(async () => {
await result.current.send('a message from me');
});
// The newest message is now mine — reading it would be reading myself.
const mine = result.current.messages.filter((m) => m.mine).map((m) => m.id);
for (const call of markRead.mock.calls) expect(mine).not.toContain(call[1]);
});
it('does not mark my message seen just because the receipt came back', async () => {
const adapter = new MockAdapter();
const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
await result.current.send('hello there');
});
const mine = result.current.messages.find((m) => m.mine && m.text === 'hello there');
expect(mine).toBeDefined();
expect(result.current.seenIds.has(mine!.id)).toBe(false);
});
});
@@ -0,0 +1,221 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useAdapter } from '../provider';
import { isOwnMessage } from '../types';
import type { Attachment, Message, SendOpts } from '../types';
const TYPING_TTL_MS = 3500;
export interface UiMessage extends Message {
mine: boolean;
}
export interface MessagesState {
messages: UiMessage[];
loading: boolean;
error: string | null;
send: (content: string, opts?: SendOpts) => Promise<void>;
react: (messageId: string, emoji: string) => Promise<void>;
upload: (file: File) => Promise<Attachment>;
typingUserIds: string[];
seenIds: Set<string>;
sendTyping: () => void;
canReact: boolean;
canUpload: boolean;
}
let optimisticSeq = 0;
export function useMessages(threadId: string | null): MessagesState {
const adapter = useAdapter();
const [raw, setRaw] = useState<Message[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [typing, setTyping] = useState<Record<string, number>>({});
const [seenIds, setSeenIds] = useState<Set<string>>(new Set());
const currentActorId = adapter.currentActorId();
const actorRef = useRef(currentActorId);
actorRef.current = currentActorId;
// Load history, then subscribe. Reconciliation is by message id, so an echoed
// send never duplicates the optimistic row.
useEffect(() => {
if (!threadId) {
setRaw([]);
setLoading(false);
return;
}
let alive = true;
setLoading(true);
setRaw([]);
setError(null);
setSeenIds(new Set());
setTyping({});
adapter
.history(threadId)
.then((h) => {
if (!alive) return;
// Merge, don't clobber: a live message can arrive via subscribe while this
// history fetch is still in flight. Blindly setting raw = h would drop it.
setRaw((live) => {
const histIds = new Set(h.map((m) => m.id));
const extras = live.filter((m) => !histIds.has(m.id));
return extras.length ? [...h, ...extras] : h;
});
setError(null);
})
.catch((e: unknown) => {
if (alive) setError(e instanceof Error ? e.message : String(e));
})
.finally(() => {
if (alive) setLoading(false);
});
const off = adapter.subscribe(threadId, (e) => {
if (!alive) return;
switch (e.kind) {
case 'message':
setRaw((l) => (l.some((m) => m.id === e.message.id) ? l : [...l, e.message]));
break;
case 'typing':
if (e.userId !== actorRef.current) {
setTyping((t) => ({ ...t, [e.userId]: Date.now() + TYPING_TTL_MS }));
}
break;
case 'receipt':
// Only the OTHER side reading my message counts as "seen".
if (e.actorId !== actorRef.current) {
setSeenIds((s) => (s.has(e.messageId) ? s : new Set(s).add(e.messageId)));
}
break;
case 'reaction':
setRaw((l) => l.map((m) => (m.id === e.messageId ? { ...m, reactions: e.reactions } : m)));
break;
}
});
return () => {
alive = false;
off();
};
}, [adapter, threadId]);
const messages: UiMessage[] = useMemo(
() => raw.map((m) => ({ ...m, mine: isOwnMessage(m, currentActorId) })),
[raw, currentActorId],
);
const send = useCallback(
async (content: string, opts?: SendOpts) => {
if (!threadId) return;
const tempId = `optimistic_${optimisticSeq++}`;
const optimistic: Message = {
id: tempId,
actorId: actorRef.current,
text: content,
at: new Date().toISOString(),
pending: true,
reactions: [],
...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}),
...(opts?.attachment ? { attachment: opts.attachment } : {}),
};
setRaw((l) => [...l, optimistic]);
try {
const saved = await adapter.send(threadId, content, opts);
setError(null);
// Replace the optimistic row with the server's. If the subscribe echo already
// added the real message, just drop the optimistic one.
setRaw((l) => {
const withoutTemp = l.filter((m) => m.id !== tempId);
return withoutTemp.some((m) => m.id === saved.id) ? withoutTemp : [...withoutTemp, saved];
});
} catch (e: unknown) {
setRaw((l) => l.filter((m) => m.id !== tempId));
setError(e instanceof Error ? e.message : String(e));
throw e;
}
},
[adapter, threadId],
);
const react = useCallback(
async (messageId: string, emoji: string) => {
if (!threadId || !adapter.react) return;
await adapter.react(threadId, messageId, emoji);
},
[adapter, threadId],
);
const upload = useCallback(
async (file: File): Promise<Attachment> => {
if (!adapter.upload) throw new Error('uploads are not supported by this adapter');
return adapter.upload(file);
},
[adapter],
);
const sendTyping = useCallback(() => {
if (threadId) adapter.sendTyping(threadId);
}, [adapter, threadId]);
// The newest acknowledged (non-pending) message id from SOMEONE ELSE — what we report as read.
//
// Skipping my own messages is load-bearing, not tidiness: reporting a read of the message I just
// sent makes the server broadcast a receipt for it, and the sender's own client then paints it
// "Seen" the instant it is delivered, before anyone has looked at it.
const lastReadableId = useMemo(() => {
for (let i = raw.length - 1; i >= 0; i--) {
const m = raw[i]!;
if (m.pending || isOwnMessage(m, currentActorId)) continue;
return m.id;
}
return null;
}, [raw, currentActorId]);
// Report my read of the newest message (drives the other side's "seen" tick).
// Keyed on the id, not the whole array, so reaction/optimistic churn doesn't re-fire it.
useEffect(() => {
if (!threadId || !lastReadableId) return;
void adapter.markRead(threadId, lastReadableId).catch(() => {});
}, [adapter, threadId, lastReadableId]);
const typingUserIds = useMemo(() => {
const now = Date.now();
return Object.entries(typing)
.filter(([, exp]) => exp > now)
.map(([u]) => u);
}, [typing]);
// Expire stale typing entries. Bumping `typing` to a new reference forces the
// memo above to recompute with a fresh `now`, dropping entries past their TTL.
// (A bump of unrelated state can't do this — the memo is keyed on `typing`, so it
// would return its cached array and the indicator would stick forever.)
useEffect(() => {
if (typingUserIds.length === 0) return;
const t = setTimeout(() => setTyping((p) => ({ ...p })), TYPING_TTL_MS);
return () => clearTimeout(t);
}, [typingUserIds.length, typing]);
// Only my messages that the other side has read.
const seenMine = useMemo(() => {
const out = new Set<string>();
for (const id of seenIds) if (messages.some((m) => m.id === id && m.mine)) out.add(id);
return out;
}, [seenIds, messages]);
return {
messages,
loading,
error,
send,
react,
upload,
typingUserIds,
seenIds: seenMine,
sendTyping,
canReact: typeof adapter.react === 'function',
canUpload: typeof adapter.upload === 'function',
};
}
@@ -0,0 +1,36 @@
import type { MailAttachment, InboxItem, InboxState, MailMessage, MailPerson } from './types';
/**
* The inbox seam. A host implements this; the SDK renders it. Mirrors MessagingAdapter's philosophy:
* `listInbox` returns the UNIFIED feed (work items + mail folded in) so the folding logic lives in
* one place (the adapter, which knows both sources). Compose methods are optional and degrade.
*/
export interface InboxAdapter {
/** The unified inbox: work items + mail threads as rows, filtered by state (mail shows in OPEN). */
listInbox(state?: InboxState): Promise<InboxItem[]>;
/** Transition a work item's state (Done/Snooze/Archive…). Mail rows are not transitioned. */
transition(id: string, state: InboxState): Promise<void>;
/** Messages of one mail thread (HTML + text parts) for the reader. */
mailHistory(threadId: string): Promise<MailMessage[]>;
/** Reply into an existing mail thread, optionally with one attachment. */
mailReply(threadId: string, content: string, attachment?: MailAttachment): Promise<void>;
// ── Attachments (optional) — absent hides the attach affordance ──
/** Upload a file to storage, returning a reference to send with a reply/compose. */
uploadAttachment?(file: File): Promise<MailAttachment>;
/** Resolve a short-lived URL to view/download an attachment. Absent => attachment chips are
* shown but not clickable. */
downloadAttachment?(attachment: MailAttachment): Promise<string>;
// ── Compose (optional) — absent hides the "New message" affordance ──
/** People you can compose an in-app message to. */
directory?(): Promise<MailPerson[]>;
/** App-to-app mail (no SMTP) to a registered user's in-app inbox. */
composeInternal?(recipientUserId: string, subject: string, text: string, attachments?: MailAttachment[]): Promise<void>;
/** External email (SMTP) to an address. */
composeExternal?(target: string, subject: string, text: string, attachments?: MailAttachment[]): Promise<void>;
}
@@ -0,0 +1,193 @@
import { useCallback, useEffect, useState } from 'react';
import { useInboxAdapter } from './provider';
import type { InboxItem, InboxState, MailAttachment, MailMessage, MailPerson } from './types';
export interface InboxData {
items: InboxItem[];
loading: boolean;
error: string | null;
transition: (id: string, state: InboxState) => Promise<void>;
refetch: () => void;
}
/** The unified inbox for a given filter state. Refetches when the filter changes. */
export function useInbox(state?: InboxState): InboxData {
const adapter = useInboxAdapter();
const [items, setItems] = useState<InboxItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [nonce, setNonce] = useState(0);
useEffect(() => {
let alive = true;
setLoading(true);
adapter
.listInbox(state)
.then((list) => {
if (!alive) return;
setItems(list);
setError(null);
})
.catch((e: unknown) => {
if (!alive) return;
setError(e instanceof Error ? e.message : String(e));
setItems([]);
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, [adapter, state, nonce]);
const refetch = useCallback(() => setNonce((n) => n + 1), []);
const transition = useCallback(
async (id: string, next: InboxState) => {
await adapter.transition(id, next);
setNonce((n) => n + 1);
},
[adapter],
);
return { items, loading, error, transition, refetch };
}
export interface MailThreadState {
messages: MailMessage[];
loading: boolean;
error: string | null;
reply: (content: string, attachment?: MailAttachment) => Promise<void>;
canAttach: boolean;
upload: (file: File) => Promise<MailAttachment>;
canDownload: boolean;
download: (attachment: MailAttachment) => Promise<string>;
refetch: () => void;
}
/** One mail thread: history + reply. */
export function useMailThread(threadId: string | null): MailThreadState {
const adapter = useInboxAdapter();
const [messages, setMessages] = useState<MailMessage[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [nonce, setNonce] = useState(0);
useEffect(() => {
if (!threadId) {
setMessages([]);
setLoading(false);
return;
}
let alive = true;
setLoading(true);
adapter
.mailHistory(threadId)
.then((m) => {
if (!alive) return;
setMessages(m);
setError(null);
})
.catch((e: unknown) => {
if (alive) setError(e instanceof Error ? e.message : String(e));
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, [adapter, threadId, nonce]);
const refetch = useCallback(() => setNonce((n) => n + 1), []);
const reply = useCallback(
async (content: string, attachment?: MailAttachment) => {
if (!threadId) return;
await adapter.mailReply(threadId, content, attachment);
setNonce((n) => n + 1);
},
[adapter, threadId],
);
const upload = useCallback(
async (file: File) => {
if (!adapter.uploadAttachment) throw new Error('attachments are not supported by this adapter');
return adapter.uploadAttachment(file);
},
[adapter],
);
const download = useCallback(
async (attachment: MailAttachment) => {
if (!adapter.downloadAttachment) throw new Error('attachment download is not supported by this adapter');
return adapter.downloadAttachment(attachment);
},
[adapter],
);
return {
messages,
loading,
error,
reply,
canAttach: typeof adapter.uploadAttachment === 'function',
upload,
canDownload: typeof adapter.downloadAttachment === 'function',
download,
refetch,
};
}
export interface ComposeState {
supported: boolean;
directory: MailPerson[];
sendInternal: (recipientUserId: string, subject: string, text: string, attachments?: MailAttachment[]) => Promise<void>;
sendExternal: (target: string, subject: string, text: string, attachments?: MailAttachment[]) => Promise<void>;
canAttach: boolean;
upload: (file: File) => Promise<MailAttachment>;
}
/** Compose a new message — in-app (to a person) or external (to an email). */
export function useCompose(): ComposeState {
const adapter = useInboxAdapter();
const supported = typeof adapter.composeInternal === 'function';
const [directory, setDirectory] = useState<MailPerson[]>([]);
useEffect(() => {
if (!adapter.directory) return;
let alive = true;
adapter
.directory()
.then((d) => {
if (alive) setDirectory(d);
})
.catch(() => {
if (alive) setDirectory([]);
});
return () => {
alive = false;
};
}, [adapter]);
const sendInternal = useCallback(
async (recipientUserId: string, subject: string, text: string, attachments?: MailAttachment[]) => {
if (!adapter.composeInternal) throw new Error('compose is not supported by this adapter');
await adapter.composeInternal(recipientUserId, subject, text, attachments);
},
[adapter],
);
const sendExternal = useCallback(
async (target: string, subject: string, text: string, attachments?: MailAttachment[]) => {
if (!adapter.composeExternal) throw new Error('compose is not supported by this adapter');
await adapter.composeExternal(target, subject, text, attachments);
},
[adapter],
);
const upload = useCallback(
async (file: File) => {
if (!adapter.uploadAttachment) throw new Error('attachments are not supported by this adapter');
return adapter.uploadAttachment(file);
},
[adapter],
);
return { supported, directory, sendInternal, sendExternal, canAttach: typeof adapter.uploadAttachment === 'function', upload };
}
@@ -0,0 +1,97 @@
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { InboxProvider } from './provider';
import { Inbox } from './inbox';
import { MockInboxAdapter } from '../adapters/mock-inbox';
function mount() {
return render(
<InboxProvider adapter={new MockInboxAdapter()}>
<Inbox />
</InboxProvider>,
);
}
describe('mock inbox adapter', () => {
it('unifies work items + mail in the Open view; other states drop mail', async () => {
const a = new MockInboxAdapter();
const open = await a.listInbox('OPEN');
expect(open.some((i) => i.kind === 'MAIL')).toBe(true);
expect(open.some((i) => i.kind === 'MENTION')).toBe(true);
const done = await a.listInbox('DONE');
expect(done.some((i) => i.kind === 'MAIL')).toBe(false);
});
it('transition moves a work item out of Open', async () => {
const a = new MockInboxAdapter();
await a.transition('in_3', 'DONE');
expect((await a.listInbox('OPEN')).some((i) => i.id === 'in_3')).toBe(false);
expect((await a.listInbox('DONE')).some((i) => i.id === 'in_3')).toBe(true);
});
it('mail reply appends to the thread', async () => {
const a = new MockInboxAdapter();
await a.mailReply('mt_welcome', 'thanks!');
expect((await a.mailHistory('mt_welcome')).some((m) => m.text === 'thanks!')).toBe(true);
});
it('reply carries an uploaded attachment', async () => {
const a = new MockInboxAdapter();
const ref = await a.uploadAttachment(new File(['x'], 'plan.pdf', { type: 'application/pdf' }));
expect(ref).toMatchObject({ filename: 'plan.pdf', mimeType: 'application/pdf' });
await a.mailReply('mt_welcome', '', ref);
const last = (await a.mailHistory('mt_welcome')).at(-1)!;
expect(last.attachment?.filename).toBe('plan.pdf');
});
it('composeInternal carries a first attachment onto the new thread', async () => {
const a = new MockInboxAdapter();
const ref = await a.uploadAttachment(new File(['x'], 'quote.png', { type: 'image/png' }));
await a.composeInternal('pp_sofia', 'Quote', 'see attached', [ref]);
const open = await a.listInbox('OPEN');
const row = open.find((i) => i.title === 'Quote')!;
expect((await a.mailHistory(row.threadId!)).at(-1)?.attachment?.filename).toBe('quote.png');
});
});
describe('<Inbox /> (rendered)', () => {
it('lists items and opens a mail thread on click', async () => {
mount();
// A folded mail row is present.
const welcome = await screen.findByText('Welcome to the Founders Club');
fireEvent.click(welcome);
// The reader opens with a reply box.
expect(await screen.findByLabelText('Reply')).toBeTruthy();
});
it('replies into a mail thread', async () => {
mount();
fireEvent.click(await screen.findByText('Welcome to the Founders Club'));
const input = (await screen.findByLabelText('Reply')) as HTMLInputElement;
fireEvent.change(input, { target: { value: 'got it' } });
fireEvent.click(screen.getByText('Reply'));
await waitFor(() => expect(screen.getByText('got it')).toBeTruthy());
});
it('attaches a file into a mail reply', async () => {
mount();
fireEvent.click(await screen.findByText('Welcome to the Founders Club'));
const file = new File(['data'], 'roof.pdf', { type: 'application/pdf' });
fireEvent.change(await screen.findByLabelText('Attach file'), { target: { files: [file] } });
// The pending chip shows the file, then Reply sends it.
await screen.findByText(/roof\.pdf/);
fireEvent.click(screen.getByText('Reply'));
await waitFor(() => expect(screen.getAllByText(/roof\.pdf/).length).toBeGreaterThan(0));
});
it('composes an in-app message and it shows in the inbox', async () => {
mount();
fireEvent.click(await screen.findByText('New message'));
// pick a recipient
fireEvent.click(await screen.findByText('Sofia Ramirez'));
fireEvent.change(screen.getByLabelText('Subject'), { target: { value: 'Quick q' } });
fireEvent.change(screen.getByLabelText('Message body'), { target: { value: 'ping' } });
fireEvent.click(screen.getByText('Send'));
await waitFor(() => expect(screen.getByText('Quick q')).toBeTruthy());
});
});
@@ -0,0 +1,371 @@
import { useEffect, useRef, useState, type FormEvent } from 'react';
import { ModalPortal } from '../components/modal-portal';
import { useCompose, useInbox, useMailThread } from './hooks';
import type { InboxItem, InboxState, MailAttachment, MailPerson } from './types';
const fmtBytes = (n: number): string => {
if (!n) return '';
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
};
const FILTERS: { value: InboxState; label: string }[] = [
{ value: 'OPEN', label: 'Open' },
{ value: 'SNOOZED', label: 'Snoozed' },
{ value: 'DONE', label: 'Done' },
{ value: 'ARCHIVED', label: 'Archived' },
];
const KIND_LABEL: Record<string, string> = {
MAIL: 'Mail',
MENTION: 'Mention',
NEEDS_REPLY: 'Needs reply',
SYSTEM_ALERT: 'Alert',
SUPPORT_UPDATE: 'Support',
};
const timeOf = (iso?: string): string => {
if (!iso) return '';
const d = new Date(iso);
return Number.isNaN(+d) ? '' : d.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
};
/** The unified inbox: work items + mail in one list; click a threaded row to read + reply. */
export function Inbox({ focusThreadId }: { focusThreadId?: string | null } = {}) {
const [filter, setFilter] = useState<InboxState>('OPEN');
const { items, loading, error, transition, refetch } = useInbox(filter);
const compose = useCompose();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [composing, setComposing] = useState(false);
// Deep link (e.g. from global search): mail lives in the Open view, so switch there and let the
// selection effect pick the matching thread once the list loads.
useEffect(() => {
if (focusThreadId) setFilter('OPEN');
}, [focusThreadId]);
useEffect(() => {
if (focusThreadId) {
const hit = items.find((i) => i.threadId === focusThreadId);
if (hit) {
setSelectedId(hit.id);
return;
}
}
if (selectedId && items.some((i) => i.id === selectedId)) return;
setSelectedId(items[0]?.id ?? null);
}, [items, selectedId, focusThreadId]);
const selected = items.find((i) => i.id === selectedId) ?? null;
return (
<div className="miu-inbox">
<div className="miu-inbox-bar">
<div className="miu-inbox-filters">
{FILTERS.map((f) => (
<button key={f.value} type="button" className={`miu-tab${filter === f.value ? ' is-active' : ''}`} onClick={() => setFilter(f.value)}>
{f.label}
</button>
))}
</div>
{compose.supported ? (
<button type="button" className="miu-send" onClick={() => setComposing(true)}>
New message
</button>
) : null}
</div>
<div className="miu-inbox-body">
<aside className="miu-inbox-list">
{loading && items.length === 0 ? <div className="miu-empty">Loading</div> : null}
{error ? <div className="miu-empty miu-error">{error}</div> : null}
{!loading && items.length === 0 ? <div className="miu-empty">Nothing here you&apos;re all caught up 🎉</div> : null}
{items.map((it) => (
<button key={it.id} type="button" className={`miu-inbox-row${it.id === selectedId ? ' is-active' : ''}`} onClick={() => setSelectedId(it.id)}>
<span className="miu-inbox-glyph" aria-hidden="true">{it.kind === 'MAIL' ? '✉' : it.kind === 'MENTION' ? '@' : '•'}</span>
<span className="miu-inbox-main">
<span className="miu-inbox-row-top">
<span className="miu-pill">{KIND_LABEL[it.kind] ?? it.kind}</span>
<span className="miu-inbox-title">{it.title}</span>
</span>
{it.summary ? <span className="miu-inbox-summary">{it.summary}</span> : null}
</span>
{it.state !== 'OPEN' ? <span className="miu-pill">{it.state.toLowerCase()}</span> : null}
</button>
))}
</aside>
<section className="miu-inbox-detail">
{selected ? <Detail item={selected} onTransition={(s) => void transition(selected.id, s)} /> : <div className="miu-empty miu-thread-empty">Select an item to read.</div>}
</section>
</div>
{composing ? <ComposeModal onClose={() => setComposing(false)} onSent={() => { setComposing(false); refetch(); }} /> : null}
</div>
);
}
function Detail({ item, onTransition }: { item: InboxItem; onTransition: (state: InboxState) => void }) {
return (
<div className="miu-detail">
{item.state === 'OPEN' && item.kind !== 'MAIL' ? (
<div className="miu-detail-actions">
<button type="button" className="miu-tab" onClick={() => onTransition('SNOOZED')}>Snooze</button>
<button type="button" className="miu-tab" onClick={() => onTransition('DONE')}>Done</button>
<button type="button" className="miu-tab" onClick={() => onTransition('ARCHIVED')}>Archive</button>
</div>
) : null}
{item.threadId ? (
<MailReader threadId={item.threadId} subject={item.title} />
) : (
<div className="miu-detail-body">
<div className="miu-detail-title">{item.title}</div>
{item.summary ? <div className="miu-detail-summary">{item.summary}</div> : null}
</div>
)}
</div>
);
}
/** Read a mail thread (HTML in a sandboxed iframe) + reply. */
export function MailReader({ threadId, subject }: { threadId: string; subject: string }) {
const { messages, loading, error, reply, canAttach, upload, canDownload, download } = useMailThread(threadId);
const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false);
const [pending, setPending] = useState<MailAttachment | null>(null);
const [attaching, setAttaching] = useState(false);
const [uploadingName, setUploadingName] = useState<string | null>(null);
const [attachErr, setAttachErr] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
async function openAttachment(att: MailAttachment): Promise<void> {
try {
const url = await download(att);
window.open(url, '_blank', 'noopener,noreferrer');
} catch (err) {
setAttachErr(err instanceof Error ? err.message : String(err));
}
}
async function pick(e: React.ChangeEvent<HTMLInputElement>): Promise<void> {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
setAttaching(true);
setUploadingName(file.name);
setAttachErr(null);
try {
setPending(await upload(file));
} catch (err) {
setAttachErr(err instanceof Error ? err.message : String(err));
} finally {
setAttaching(false);
setUploadingName(null);
}
}
async function submit(e: FormEvent): Promise<void> {
e.preventDefault();
const text = draft.trim();
if ((!text && !pending) || sending) return;
const att = pending;
setDraft('');
setPending(null);
setSending(true);
try {
await reply(text, att ?? undefined);
} catch {
setDraft(text);
setPending(att);
} finally {
setSending(false);
}
}
return (
<div className="miu-mail">
<header className="miu-mail-head">{subject || '(no subject)'}</header>
<div className="miu-mail-body">
{loading && messages.length === 0 ? <div className="miu-empty">Loading</div> : null}
{error ? <div className="miu-empty miu-error">{error}</div> : null}
{messages.map((m) => (
<article key={m.id} className="miu-mail-msg">
<div className="miu-mail-meta">
<span>{m.kind === 'EMAIL' ? 'Email' : 'Reply'}{m.actorId ? ` · ${m.actorId}` : ''}</span>
<span>{timeOf(m.at)}</span>
</div>
{m.html ? (
<iframe sandbox="" srcDoc={m.html} title="mail body" className="miu-mail-frame" />
) : m.text ? (
<div className="miu-mail-text">{m.text}</div>
) : null}
{m.attachment ? (
canDownload ? (
<button type="button" className="miu-attach-chip miu-attach-dl" onClick={() => void openAttachment(m.attachment!)} title="Download">
📎 {m.attachment.filename ?? 'attachment'}{m.attachment.sizeBytes ? ` · ${fmtBytes(m.attachment.sizeBytes)}` : ''}
</button>
) : (
<span className="miu-attach-chip">📎 {m.attachment.filename ?? 'attachment'}{m.attachment.sizeBytes ? ` · ${fmtBytes(m.attachment.sizeBytes)}` : ''}</span>
)
) : null}
</article>
))}
</div>
<form className="miu-composer" onSubmit={submit}>
{attachErr ? <div className="miu-empty miu-error">{attachErr}</div> : null}
{attaching && uploadingName ? (
<div className="miu-attach-pending">
<span className="miu-attach-chip is-uploading"><span className="miu-spinner" aria-hidden="true" /> {uploadingName} · uploading</span>
</div>
) : pending ? (
<div className="miu-attach-pending">
<span className="miu-attach-chip">📎 {pending.filename ?? 'attachment'}{pending.sizeBytes ? ` · ${fmtBytes(pending.sizeBytes)}` : ''}</span>
<button type="button" className="miu-attach-x" onClick={() => setPending(null)} aria-label="Remove attachment"></button>
</div>
) : null}
<div className="miu-composer-row">
{canAttach ? (
<>
<input ref={fileRef} type="file" hidden onChange={pick} aria-label="Attach file" />
<button type="button" className="miu-attach-btn" onClick={() => fileRef.current?.click()} disabled={attaching || !!pending} title="Attach a file" aria-label="Attach a file">
{attaching ? '…' : '📎'}
</button>
</>
) : null}
<input className="miu-input" value={draft} onChange={(e) => setDraft(e.target.value)} placeholder="Reply…" aria-label="Reply" />
<button type="submit" className="miu-send" disabled={(!draft.trim() && !pending) || sending}>Reply</button>
</div>
</form>
</div>
);
}
function ComposeModal({ onClose, onSent }: { onClose: () => void; onSent: () => void }) {
const compose = useCompose();
const [mode, setMode] = useState<'internal' | 'external'>('internal');
const [recipient, setRecipient] = useState('');
const [subject, setSubject] = useState('');
const [body, setBody] = useState('');
const [q, setQ] = useState('');
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [attachments, setAttachments] = useState<MailAttachment[]>([]);
const [attaching, setAttaching] = useState(false);
const [uploadingName, setUploadingName] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const filtered = compose.directory.filter((p) => p.name.toLowerCase().includes(q.trim().toLowerCase()));
const canSend = !!recipient && !!subject.trim() && (!!body.trim() || attachments.length > 0) && !busy && !attaching;
async function pick(e: React.ChangeEvent<HTMLInputElement>): Promise<void> {
const file = e.target.files?.[0];
e.target.value = '';
if (!file || attachments.length >= 10) return;
setAttaching(true);
setUploadingName(file.name);
setErr(null);
try {
const ref = await compose.upload(file);
setAttachments((a) => [...a, ref]);
} catch (e2) {
setErr(e2 instanceof Error ? e2.message : String(e2));
} finally {
setAttaching(false);
setUploadingName(null);
}
}
async function send(): Promise<void> {
if (!canSend) return;
setBusy(true);
setErr(null);
const atts = attachments.length > 0 ? attachments : undefined;
try {
if (mode === 'internal') await compose.sendInternal(recipient, subject.trim(), body.trim(), atts);
else await compose.sendExternal(recipient.trim(), subject.trim(), body.trim(), atts);
onSent();
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
}
return (
<ModalPortal>
<div className="miu-modal-overlay" onMouseDown={onClose}>
<div className="miu-modal" role="dialog" aria-modal="true" onMouseDown={(e) => e.stopPropagation()}>
<div className="miu-modal-head">
<span>New message</span>
<button type="button" className="miu-pane-close" onClick={onClose} aria-label="Close"></button>
</div>
<div className="miu-modal-body">
<div className="miu-compose-modes">
<button type="button" className={`miu-tab${mode === 'internal' ? ' is-active' : ''}`} onClick={() => { setMode('internal'); setRecipient(''); }}>In-app</button>
<button type="button" className={`miu-tab${mode === 'external' ? ' is-active' : ''}`} onClick={() => { setMode('external'); setRecipient(''); }}>Email</button>
</div>
{mode === 'internal' ? (
<div className="miu-field">
<span className="miu-field-lbl">To (person)</span>
<input className="miu-input" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search people…" aria-label="Search people" />
<div className="miu-people">
{filtered.length === 0 ? <div className="miu-empty">No people found.</div> : null}
{filtered.map((p: MailPerson) => (
<label key={p.id} className={`miu-person${recipient === p.id ? ' is-active' : ''}`}>
<input type="radio" name="miu-recipient" checked={recipient === p.id} onChange={() => setRecipient(p.id)} />
<span>{p.name}</span>
<span className="miu-pill">{p.kind}</span>
</label>
))}
</div>
</div>
) : (
<div className="miu-field">
<span className="miu-field-lbl">To (email)</span>
<input className="miu-input" value={recipient} onChange={(e) => setRecipient(e.target.value)} placeholder="name@company.com" aria-label="To email" />
</div>
)}
<div className="miu-field">
<span className="miu-field-lbl">Subject</span>
<input className="miu-input" value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="Subject" aria-label="Subject" />
</div>
<div className="miu-field">
<span className="miu-field-lbl">Message</span>
<textarea className="miu-input miu-textarea" value={body} onChange={(e) => setBody(e.target.value)} placeholder="Write your message…" rows={5} aria-label="Message body" />
</div>
{compose.canAttach ? (
<div className="miu-field">
<span className="miu-field-lbl">Attachments</span>
<div className="miu-attach-list">
{attachments.map((a, i) => (
<span key={`${a.contentRef}-${i}`} className="miu-attach-pending">
<span className="miu-attach-chip">📎 {a.filename ?? 'attachment'}{a.sizeBytes ? ` · ${fmtBytes(a.sizeBytes)}` : ''}</span>
<button type="button" className="miu-attach-x" onClick={() => setAttachments((prev) => prev.filter((_, j) => j !== i))} aria-label="Remove attachment"></button>
</span>
))}
{attaching && uploadingName ? (
<span className="miu-attach-pending">
<span className="miu-attach-chip is-uploading"><span className="miu-spinner" aria-hidden="true" /> {uploadingName} · uploading</span>
</span>
) : null}
<input ref={fileRef} type="file" hidden onChange={pick} aria-label="Attach file" />
<button type="button" className="miu-tab" onClick={() => fileRef.current?.click()} disabled={attaching || attachments.length >= 10}>
📎 Attach
</button>
</div>
</div>
) : null}
{err ? <div className="miu-empty miu-error">{err}</div> : null}
</div>
<div className="miu-modal-foot">
<button type="button" className="miu-tab" onClick={onClose}>Cancel</button>
<button type="button" className="miu-send" onClick={() => void send()} disabled={!canSend}>{busy ? 'Sending…' : 'Send'}</button>
</div>
</div>
</div>
</ModalPortal>
);
}
@@ -0,0 +1,16 @@
import { createContext, useContext, type ReactNode } from 'react';
import type { InboxAdapter } from './adapter';
const InboxContext = createContext<InboxAdapter | null>(null);
export function InboxProvider({ adapter, children }: { adapter: InboxAdapter; children: ReactNode }) {
return <InboxContext.Provider value={adapter}>{children}</InboxContext.Provider>;
}
/** Access the host-injected inbox adapter. Throws outside a provider — a missing provider is a
* wiring bug, and failing loudly beats a confusing null-deref three layers down. */
export function useInboxAdapter(): InboxAdapter {
const adapter = useContext(InboxContext);
if (!adapter) throw new Error('useInboxAdapter must be used within an <InboxProvider>');
return adapter;
}
@@ -0,0 +1,42 @@
// Domain types for the inbox surface (work items + mail). Zero transport imports.
export type InboxState = 'OPEN' | 'SNOOZED' | 'DONE' | 'ARCHIVED' | 'CANCELLED' | 'STALE';
/** A unified inbox row — a work item (mention/needs-reply/alert) OR a mail thread. */
export interface InboxItem {
id: string;
kind: string; // MAIL | MENTION | NEEDS_REPLY | SYSTEM_ALERT | …
state: InboxState;
title: string;
summary?: string;
priority: string;
/** Present when the row opens a conversation/mail thread. */
threadId?: string;
createdAt: string;
}
/** A mail attachment reference (bytes live in storage; the reader resolves a display URL). */
export interface MailAttachment {
contentRef: string;
mimeType: string;
sizeBytes: number;
filename: string | null;
}
/** One message in a mail thread — HTML and/or plain text, optionally an attachment. */
export interface MailMessage {
id: string;
actorId: string | null;
kind: string;
at: string;
html: string | null;
text: string | null;
attachment: MailAttachment | null;
}
/** A person you can compose an in-app message to. */
export interface MailPerson {
id: string;
name: string;
kind: 'staff' | 'customer';
}
+47
View File
@@ -0,0 +1,47 @@
// Public API. Components land in Plan 2; adapters/kernel in Plan 3.
//
// `runAdapterConformance` is deliberately NOT exported here — it imports vitest and
// ships from the './conformance' subpath so consumers never pull a test runner into
// their production bundle.
export { MessagingProvider, useAdapter } from './provider';
export { useConversations } from './hooks/use-conversations';
export { useMessages } from './hooks/use-messages';
export { useChannels } from './hooks/use-channels';
export { useMembers } from './hooks/use-members';
export { isOwnMessage } from './types';
// Message-body markup (bold/italic/strike/code/links + mentions) as a safe ReactNode tree.
export { renderRichText, safeHref, stripMarkup } from './rich-text';
// Rendered UI. Pair with the './styles.css' export (or override the --miu-* tokens).
export { Messenger } from './components/messenger';
export { ConversationList } from './components/conversation-list';
export { ChannelBrowser } from './components/channel-browser';
export { Thread } from './components/thread';
export { ThreadPane } from './components/thread-pane';
// ── Inbox domain (work items + mail) ──
export { InboxProvider, useInboxAdapter } from './inbox/provider';
export { useInbox, useMailThread, useCompose } from './inbox/hooks';
export { Inbox, MailReader } from './inbox/inbox';
export type { InboxAdapter } from './inbox/adapter';
export type { InboxItem, InboxState, MailAttachment, MailMessage, MailPerson } from './inbox/types';
export type { MessagingAdapter } from './adapter';
export type { ConversationsState } from './hooks/use-conversations';
export type { MessagesState, UiMessage } from './hooks/use-messages';
export type { ChannelsState } from './hooks/use-channels';
export type {
Attachment,
BulkAddResult,
ChannelSummary,
ChannelVisibility,
Conversation,
CreateChannelInput,
Membership,
Message,
MessageEvent,
Person,
Reaction,
SendOpts,
Unsubscribe,
} from './types';
@@ -0,0 +1,55 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MessagingProvider } from './provider';
import { Messenger } from './components/messenger';
import { MockAdapter } from './adapters/mock';
import { insertMention, resolveMentions, trailingMentionQuery } from './mentions';
import type { Person } from './types';
const people: Person[] = [
{ id: 'pp_sofia', name: 'Sofia Ramirez', kind: 'staff' },
{ id: 'pp_dan', name: 'Dan Whitaker', kind: 'staff' },
];
describe('mention helpers', () => {
it('trailingMentionQuery finds the @token being typed', () => {
expect(trailingMentionQuery('hey @Sof')).toBe('Sof');
expect(trailingMentionQuery('@')).toBe('');
expect(trailingMentionQuery('no mention here')).toBeNull();
expect(trailingMentionQuery('done @Sofia Ramirez ')).toBeNull(); // completed, trailing space
});
it('insertMention replaces the trailing query, keeping the boundary', () => {
expect(insertMention('hey @Sof', 'Sofia Ramirez')).toBe('hey @Sofia Ramirez ');
expect(insertMention('@ch', 'channel')).toBe('@channel ');
});
it('resolveMentions maps names to ids; @channel expands to everyone', () => {
expect(resolveMentions('ping @Sofia Ramirez', people)).toEqual(['pp_sofia']);
expect(resolveMentions('nobody here', people)).toEqual([]);
expect(resolveMentions('@channel ship it', people).sort()).toEqual(['pp_dan', 'pp_sofia']);
});
});
describe('<Thread /> @mention autocomplete', () => {
it('picking a suggestion inserts the name and send carries the mention id', async () => {
const adapter = new MockAdapter();
const sendSpy = vi.spyOn(adapter, 'send');
render(
<MessagingProvider adapter={adapter}>
<Messenger />
</MessagingProvider>,
);
const input = (await screen.findByLabelText('Message')) as HTMLInputElement;
fireEvent.change(input, { target: { value: 'hey @Sof' } });
// The suggestion (role=option) is distinct from the sidebar row of the same name.
fireEvent.click(await screen.findByRole('option', { name: 'Sofia Ramirez' }));
expect(input.value).toBe('hey @Sofia Ramirez ');
fireEvent.click(screen.getByText('Send'));
await waitFor(() => expect(sendSpy).toHaveBeenCalled());
const opts = sendSpy.mock.calls[0]![2];
expect(opts?.mentions).toContain('pp_sofia');
});
});
@@ -0,0 +1,51 @@
import type { ReactNode } from 'react';
import type { Person } from './types';
/** Room-wide mention tokens, always offered alongside members. */
export const SPECIAL_MENTIONS = ['channel', 'here'];
function esc(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/** The @token currently being typed at the end of the draft (caret-at-end), or null. */
export function trailingMentionQuery(draft: string): string | null {
const m = draft.match(/(?:^|\s)@([\w]*)$/);
return m ? (m[1] ?? '') : null;
}
/** Replace the trailing @query with `@insert ` (keeping the leading boundary). */
export function insertMention(draft: string, insert: string): string {
return draft.replace(/(^|\s)@([\w]*)$/, (_full, lead: string) => `${lead}@${insert} `);
}
/** Resolve the opaque mention userId list from the final text + known members. */
export function resolveMentions(text: string, members: Person[]): string[] {
const ids = new Set<string>();
for (const p of members) if (text.includes(`@${p.name}`)) ids.add(p.id);
if (/@channel\b/.test(text) || /@here\b/.test(text)) for (const p of members) ids.add(p.id);
return [...ids];
}
/** Render text with @mentions (member names + @channel/@here) wrapped for highlighting. */
export function highlightMentions(text: string, memberNames: string[]): ReactNode[] {
// Longest-first so "@Sofia Ramirez" wins over a bare "@Sofia".
const names = [...new Set([...memberNames, ...SPECIAL_MENTIONS])].filter(Boolean).sort((a, b) => b.length - a.length);
if (names.length === 0) return [text];
const re = new RegExp(`@(${names.map(esc).join('|')})`, 'g');
const out: ReactNode[] = [];
let last = 0;
let key = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) {
if (m.index > last) out.push(text.slice(last, m.index));
out.push(
<span key={`m${key++}`} className="miu-mention">
{m[0]}
</span>,
);
last = m.index + m[0].length;
}
if (last < text.length) out.push(text.slice(last));
return out.length > 0 ? out : [text];
}
@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MessagingProvider, useAdapter } from './provider';
import { MockAdapter } from './adapters/mock';
function ShowActor() {
const adapter = useAdapter();
return <span>{adapter.currentActorId()}</span>;
}
describe('MessagingProvider', () => {
it('supplies the injected adapter to descendants', () => {
render(
<MessagingProvider adapter={new MockAdapter()}>
<ShowActor />
</MessagingProvider>,
);
expect(screen.getByText('me')).toBeDefined();
});
it('throws a helpful error when a hook is used outside the provider', () => {
// React logs the error boundary trace; that noise is expected.
expect(() => render(<ShowActor />)).toThrow(/useAdapter must be used within a <MessagingProvider>/);
});
});
@@ -0,0 +1,22 @@
import { createContext, useContext, type ReactNode } from 'react';
import type { MessagingAdapter } from './adapter';
const AdapterContext = createContext<MessagingAdapter | null>(null);
export function MessagingProvider({
adapter,
children,
}: {
adapter: MessagingAdapter;
children: ReactNode;
}) {
return <AdapterContext.Provider value={adapter}>{children}</AdapterContext.Provider>;
}
/** Access the host-injected adapter. Throws outside a provider a missing provider is
* a wiring bug, and failing loudly beats a confusing null-deref three layers down. */
export function useAdapter(): MessagingAdapter {
const adapter = useContext(AdapterContext);
if (!adapter) throw new Error('useAdapter must be used within a <MessagingProvider>');
return adapter;
}
@@ -0,0 +1,95 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { renderRichText, safeHref, stripMarkup } from './rich-text';
function show(text: string, names: string[] = []) {
render(<div data-testid="out">{renderRichText(text, names)}</div>);
return screen.getByTestId('out');
}
describe('renderRichText', () => {
it('renders bold, italic and strikethrough', () => {
const el = show('*bold* _italic_ ~gone~');
expect(el.querySelector('strong')?.textContent).toBe('bold');
expect(el.querySelector('em')?.textContent).toBe('italic');
expect(el.querySelector('del')?.textContent).toBe('gone');
});
it('nests styles', () => {
const el = show('*bold with _italic_ inside*');
const strong = el.querySelector('strong');
expect(strong?.textContent).toBe('bold with italic inside');
expect(strong?.querySelector('em')?.textContent).toBe('italic');
});
it('leaves markup inside code completely literal', () => {
const el = show('use `*not bold*` here');
expect(el.querySelector('code')?.textContent).toBe('*not bold*');
expect(el.querySelector('strong')).toBeNull();
});
it('renders a fenced code block', () => {
const el = show('```\nconst a = 1;\n```');
expect(el.querySelector('pre.miu-code-block')?.textContent).toBe('const a = 1;\n');
expect(el.querySelector('code')).not.toBeNull();
});
it('does not treat arithmetic or a lone marker as formatting', () => {
const el = show('5 * 3 = 15 and a lone * plus snake_case_name');
expect(el.querySelector('strong')).toBeNull();
expect(el.querySelector('em')).toBeNull();
expect(el.textContent).toBe('5 * 3 = 15 and a lone * plus snake_case_name');
});
it('linkifies http(s) and www URLs without swallowing trailing punctuation', () => {
const el = show('see https://example.com/a?b=1, ok');
const a = el.querySelector('a');
expect(a?.getAttribute('href')).toBe('https://example.com/a?b=1');
expect(a?.textContent).toBe('https://example.com/a?b=1');
expect(a?.getAttribute('rel')).toContain('noopener');
expect(el.textContent).toContain(', ok');
});
it('never renders a javascript: URL as a link (XSS guard)', () => {
expect(safeHref('javascript:alert(1)')).toBeNull();
expect(safeHref('data:text/html,<script>')).toBeNull();
expect(safeHref('https://ok.example')).toBe('https://ok.example/');
// and it is not linkified in message text either
const el = show('javascript:alert(1)');
expect(el.querySelector('a')).toBeNull();
});
it('escapes nothing as HTML — angle brackets stay text', () => {
const el = show('<img src=x onerror=alert(1)>');
expect(el.querySelector('img')).toBeNull();
expect(el.textContent).toBe('<img src=x onerror=alert(1)>');
});
it('still highlights @mentions alongside formatting', () => {
const el = show('*hi* @Sofia Ramirez', ['Sofia Ramirez']);
expect(el.querySelector('strong')?.textContent).toBe('hi');
expect(el.querySelector('.miu-mention')?.textContent).toBe('@Sofia Ramirez');
});
it('returns plain text unchanged when there is no markup', () => {
expect(show('just a normal message').textContent).toBe('just a normal message');
});
});
describe('stripMarkup (one-line previews)', () => {
it('drops the markers so a preview never shows ~crazy~', () => {
expect(stripMarkup('~crazy~')).toBe('crazy');
expect(stripMarkup('*bold* and _italic_')).toBe('bold and italic');
});
it('unwraps code and flattens a fenced block', () => {
expect(stripMarkup('run `npm ci` now')).toBe('run npm ci now');
expect(stripMarkup('```\nconst a = 1;\n```')).toBe('const a = 1;');
});
it('handles nesting and leaves ordinary text (and identifiers) alone', () => {
expect(stripMarkup('*bold with _italic_*')).toBe('bold with italic');
expect(stripMarkup('snake_case_name and 5 * 3')).toBe('snake_case_name and 5 * 3');
expect(stripMarkup('plain preview')).toBe('plain preview');
});
});
@@ -0,0 +1,162 @@
import type { ReactNode } from 'react';
import { highlightMentions } from './mentions';
/**
* WhatsApp-style lightweight markup for message bodies.
*
* *bold* _italic_ ~strike~ `code` ```code block``` plus bare URLs
*
* Messages stay PLAIN TEXT on the wire this only affects rendering, so nothing already stored
* breaks and a client without this build just shows the marker characters.
*
* Security: this returns a ReactNode tree and never touches dangerouslySetInnerHTML, so message
* text can never inject markup. The one genuinely dangerous surface is links, where an attacker
* could otherwise smuggle `javascript:` {@link safeHref} allows only http/https/mailto.
*
* Precedence (deliberate): code is tokenized first and its contents are left completely alone, so
* `*not bold*` inside backticks stays literal. Everything else may nest (*bold with _italic_*).
*/
/** Only protocols that cannot execute script. Anything else renders as plain text, not a link. */
export function safeHref(raw: string): string | null {
try {
const url = new URL(raw);
return ['http:', 'https:', 'mailto:'].includes(url.protocol) ? url.href : null;
} catch {
return null;
}
}
// A bare URL: stops before trailing punctuation so "see https://x.com." doesn't swallow the period.
const URL_RE = /\bhttps?:\/\/[^\s<>()]+[^\s<>().,;:!?'"]|\bwww\.[^\s<>()]+[^\s<>().,;:!?'"]/g;
interface Rule {
/** The wrapping marker, e.g. '*' for bold. */
marker: string;
tag: 'strong' | 'em' | 'del';
}
const RULES: Rule[] = [
{ marker: '*', tag: 'strong' },
{ marker: '_', tag: 'em' },
{ marker: '~', tag: 'del' },
];
/** Escape a marker so it is literal inside a RegExp. */
function esc(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Find the first inline span (`*bold*`, `_italic_`, `~strike~`) in `text`.
*
* Two guards keep everyday prose from being mangled:
* - the content must not start or end with whitespace, so "5 * 3 = 15" and a lone `*` are inert;
* - the markers must sit on word boundaries, so `snake_case_name` is NOT italicised (the bug
* every naive markdown renderer ships with).
*/
function firstSpan(text: string): { start: number; end: number; rule: Rule; inner: string } | null {
let best: { start: number; end: number; rule: Rule; inner: string } | null = null;
for (const rule of RULES) {
const m = esc(rule.marker);
// (lead)(marker)(inner)(marker) — `lead` keeps the boundary char out of the match itself.
const re = new RegExp(`(^|[^\\w${m}])${m}(?=\\S)([^${m}]*[^\\s${m}])${m}(?![\\w${m}])`);
const found = re.exec(text);
if (!found) continue;
const start = found.index + (found[1]?.length ?? 0);
if (best === null || start < best.start) {
best = { start, end: found.index + found[0].length, rule, inner: found[2] ?? '' };
}
}
return best;
}
/**
* The same text with its formatting markers removed, for places that show a one-line plain-text
* preview (conversation list, notifications) where `~crazy~` must read as "crazy" you cannot
* render nodes into those, so the markers have to come off rather than be styled.
*/
export function stripMarkup(text: string): string {
const noCode = text
.replace(/```([\s\S]*?)```/g, (_m, code: string) => code.trim())
.replace(/`([^`\n]+)`/g, '$1');
return stripSpans(noCode);
}
/** Recursively drop *bold* / _italic_ / ~strike~ markers, honouring the same word-boundary rule. */
function stripSpans(text: string): string {
const span = firstSpan(text);
if (!span) return text;
return text.slice(0, span.start) + stripSpans(span.inner) + stripSpans(text.slice(span.end));
}
/** Linkify + mention-highlight a run of text that carries no other markup. */
function plain(text: string, memberNames: string[], keyed: () => string): ReactNode[] {
const out: ReactNode[] = [];
let last = 0;
let m: RegExpExecArray | null;
URL_RE.lastIndex = 0;
while ((m = URL_RE.exec(text)) !== null) {
if (m.index > last) out.push(...highlightMentions(text.slice(last, m.index), memberNames));
const raw = m[0];
const href = safeHref(raw.startsWith('www.') ? `https://${raw}` : raw);
out.push(
href ? (
<a key={keyed()} className="miu-link" href={href} target="_blank" rel="noopener noreferrer nofollow">
{raw}
</a>
) : (
raw
),
);
last = m.index + raw.length;
}
if (last < text.length) out.push(...highlightMentions(text.slice(last), memberNames));
return out;
}
/** Tokenize one segment that is known to contain no code, recursing so styles can nest. */
function inline(text: string, memberNames: string[], keyed: () => string): ReactNode[] {
const span = firstSpan(text);
if (!span) return plain(text, memberNames, keyed);
const { start, end, rule, inner } = span;
const Tag = rule.tag;
return [
...(start > 0 ? inline(text.slice(0, start), memberNames, keyed) : []),
<Tag key={keyed()}>{inline(inner, memberNames, keyed)}</Tag>,
...(end < text.length ? inline(text.slice(end), memberNames, keyed) : []),
];
}
/**
* Render message text as a safe ReactNode tree: code first (its contents stay literal), then
* nested bold/italic/strike, then links and @mentions.
*/
export function renderRichText(text: string, memberNames: string[] = []): ReactNode[] {
let n = 0;
const keyed = (): string => `rt${n++}`;
const out: ReactNode[] = [];
// ```block``` or `inline` — matched together so the longer fence wins.
const CODE_RE = /```([\s\S]+?)```|`([^`\n]+)`/g;
let last = 0;
let m: RegExpExecArray | null;
while ((m = CODE_RE.exec(text)) !== null) {
if (m.index > last) out.push(...inline(text.slice(last, m.index), memberNames, keyed));
if (m[1] !== undefined) {
out.push(
<pre key={keyed()} className="miu-code-block">
<code>{m[1].replace(/^\n/, '')}</code>
</pre>,
);
} else {
out.push(
<code key={keyed()} className="miu-code">
{m[2]}
</code>,
);
}
last = m.index + m[0].length;
}
if (last < text.length) out.push(...inline(text.slice(last), memberNames, keyed));
return out.length > 0 ? out : [text];
}
@@ -0,0 +1,9 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
describe('test infrastructure', () => {
it('renders React components in jsdom', () => {
render(<div>messaging-ui</div>);
expect(screen.getByText('messaging-ui')).toBeDefined();
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,8 @@
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
// @testing-library/react auto-registers cleanup only when afterEach is a global.
// We run with globals: false, so register it explicitly.
afterEach(() => {
cleanup();
});
@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest';
import { isOwnMessage } from './types';
import type { Message } from './types';
const base: Message = {
id: 'm1',
actorId: 'actor_a',
text: 'hello',
at: '2026-07-17T10:00:00.000Z',
};
describe('isOwnMessage', () => {
it('is true when the message actor matches the current actor', () => {
expect(isOwnMessage(base, 'actor_a')).toBe(true);
});
it('is false when the actors differ', () => {
expect(isOwnMessage(base, 'actor_b')).toBe(false);
});
it('is false when the current actor is unknown', () => {
expect(isOwnMessage(base, null)).toBe(false);
});
it('is false when the message has no actor', () => {
expect(isOwnMessage({ ...base, actorId: null }, 'actor_a')).toBe(false);
});
});
+101
View File
@@ -0,0 +1,101 @@
// Domain types for the messaging UI. Zero imports on purpose: this file must never
// reach for a transport package. Adapters map their own DTOs onto these.
export type Membership = 'dm' | 'group' | 'channel';
export type ChannelVisibility = 'public' | 'private';
export interface Person {
id: string;
name: string;
kind: 'staff' | 'customer';
}
export interface Conversation {
threadId: string;
title: string;
subject: string | null;
membership: Membership | null;
participants: string[];
unread: number;
lastMessage?: string;
lastAt?: string;
/** Channel description (channels only). */
topic?: string | null;
}
/**
* Outcome of a bulk member import, reported per user so a partial result is never silent.
* `skipped` were already members (which makes a repeat import a no-op); `failed` could not be added.
*/
export interface BulkAddResult {
added: string[];
skipped: string[];
failed: string[];
}
/** A discoverable channel (from browseChannels) — includes ones the caller has NOT joined. */
export interface ChannelSummary {
threadId: string;
name: string;
topic: string | null;
visibility: ChannelVisibility;
memberCount: number;
/** True if the current user is already a member. */
joined: boolean;
}
export interface CreateChannelInput {
name: string;
topic?: string;
visibility: ChannelVisibility;
}
export interface Reaction {
emoji: string;
count: number;
mine: boolean;
}
export interface Attachment {
/** A resolved URL for display/download. May be empty until resolved by the host. */
url: string;
mime: string;
name: string;
/** Storage reference — carried so send() can persist the message part (upload returns it). */
contentRef?: string;
sizeBytes?: number;
}
export interface Message {
id: string;
actorId: string | null;
text: string;
at: string;
parentInteractionId?: string | null;
reactions?: Reaction[];
attachment?: Attachment;
/** True only when the transport is optimistic-local and not yet acknowledged. */
pending?: boolean;
}
export interface SendOpts {
parentInteractionId?: string;
attachment?: Attachment;
/** Opaque userId notify-list (from @mentions). The app parses "@"; the kernel just forwards it. */
mentions?: string[];
}
export type MessageEvent =
| { kind: 'message'; message: Message }
| { kind: 'typing'; userId: string }
| { kind: 'receipt'; messageId: string; actorId: string }
| { kind: 'reaction'; messageId: string; reactions: Reaction[] };
export type Unsubscribe = () => void;
/** Ownership is a pure function of explicit identity never inferred from history.
* See the spec: inferring it is the bug this SDK exists partly to kill. */
export function isOwnMessage(message: Message, currentActorId: string | null): boolean {
return currentActorId !== null && message.actorId !== null && message.actorId === currentActorId;
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"],
"jsx": "react-jsx",
"types": ["react"]
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'tsup';
export default defineConfig({
// `src/adapters/mock.ts` and `src/conformance.ts` are added as separate entries
// in later tasks (they don't exist yet). `conformance` in particular is its own
// entry, never reachable from `index`, because it imports vitest, and bundling
// that into the main barrel would drag a test runner into every consumer's
// production build.
entry: ['src/index.ts', 'src/adapters/mock.ts', 'src/adapters/mock-inbox.ts', 'src/adapters/kernel-client.ts', 'src/conformance.ts', 'src/styles.css'],
format: ['esm'],
// Types only for the TS entries — styles.css has no .d.ts (and tsc chokes on a .css root file).
dts: { entry: ['src/index.ts', 'src/adapters/mock.ts', 'src/adapters/mock-inbox.ts', 'src/adapters/kernel-client.ts', 'src/conformance.ts'] },
clean: true,
external: ['react', 'react-dom', 'vitest', '@insignia/iios-kernel-client'],
});
@@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config';
// This package owns its vitest config on purpose. The ROOT config includes only
// `.ts` (never `.tsx`) and provisions a Postgres DB via globalSetup for every run —
// a pure-UI package must not drag a database along, and its tests are .tsx.
export default defineConfig({
test: {
environment: 'jsdom',
include: ['src/**/*.{test,spec}.{ts,tsx}'],
setupFiles: ['./src/test-setup.ts'],
globals: false,
},
});
@@ -0,0 +1,98 @@
# Meilisearch for IIOS message search (prod).
#
# IIOS deploys to Kubernetes via ArgoCD from the platform-engineering/k8s-pods repo
# (services/iios/). This file is the template for the prod Meilisearch instance — copy it into
# k8s-pods/services/iios/meilisearch.yaml and let ArgoCD sync it. Then wire the service:
#
# 1. Create the master key once (32+ random chars) and set it in the Secret below (or via a
# sealed-secret / your secret manager — do NOT commit a real key in plaintext):
# kubectl -n <iios-namespace> create secret generic iios-meili \
# --from-literal=MEILI_MASTER_KEY="$(openssl rand -base64 32)"
# 2. Add these env vars to the iios-service Deployment (services/iios/deployment.yaml):
# - name: MEILI_URL
# value: http://meilisearch:7700
# - name: MEILI_KEY
# valueFrom: { secretKeyRef: { name: iios-meili, key: MEILI_MASTER_KEY } }
# 3. After the first deploy, backfill existing messages once: POST /v1/search/reindex per tenant
# (authenticated as a scope member) — new messages index automatically on message.sent.
#
# Set the namespace on each object (or via kustomize) to match the iios-service namespace so the
# `meilisearch` Service DNS resolves as http://meilisearch:7700 from the pod.
---
apiVersion: v1
kind: Secret
metadata:
name: iios-meili
type: Opaque
stringData:
# Replace with a real key (or manage out-of-band per note #1 and delete this stringData block).
MEILI_MASTER_KEY: "CHANGE_ME_meili_master_key"
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: iios-meili-data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 5Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: meilisearch
labels: { app: meilisearch }
spec:
replicas: 1
strategy: { type: Recreate } # single RWO volume — never run two writers
selector:
matchLabels: { app: meilisearch }
template:
metadata:
labels: { app: meilisearch }
spec:
containers:
- name: meilisearch
image: getmeili/meilisearch:v1.11
ports:
- containerPort: 7700
env:
- name: MEILI_ENV
value: "production"
- name: MEILI_NO_ANALYTICS
value: "true"
- name: MEILI_MASTER_KEY
valueFrom:
secretKeyRef: { name: iios-meili, key: MEILI_MASTER_KEY }
volumeMounts:
- name: data
mountPath: /meili_data
resources:
requests: { cpu: "100m", memory: "256Mi" }
limits: { cpu: "1", memory: "1Gi" }
readinessProbe:
httpGet: { path: /health, port: 7700 }
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet: { path: /health, port: 7700 }
initialDelaySeconds: 15
periodSeconds: 20
volumes:
- name: data
persistentVolumeClaim:
claimName: iios-meili-data
---
apiVersion: v1
kind: Service
metadata:
name: meilisearch
labels: { app: meilisearch }
spec:
selector: { app: meilisearch }
ports:
- port: 7700
targetPort: 7700
# ClusterIP (internal only) — reached by iios-service as http://meilisearch:7700.
type: ClusterIP
+1
View File
@@ -29,6 +29,7 @@
"ioredis": "^5.11.1", "ioredis": "^5.11.1",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"jwks-rsa": "^4.1.0", "jwks-rsa": "^4.1.0",
"meilisearch": "^0.45.0",
"nodemailer": "^9.0.3", "nodemailer": "^9.0.3",
"prisma": "^6.2.1", "prisma": "^6.2.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
@@ -0,0 +1,26 @@
-- CreateTable
CREATE TABLE "IiosProviderCredential" (
"id" TEXT NOT NULL,
"scopeId" TEXT NOT NULL,
"providerType" TEXT NOT NULL,
"cipherText" TEXT NOT NULL,
"iv" TEXT NOT NULL,
"authTag" TEXT NOT NULL,
"keyVersion" INTEGER NOT NULL DEFAULT 1,
"displayHints" JSONB,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "IiosProviderCredential_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "IiosProviderCredential_scopeId_idx" ON "IiosProviderCredential"("scopeId");
-- CreateIndex
CREATE UNIQUE INDEX "IiosProviderCredential_scopeId_providerType_key" ON "IiosProviderCredential"("scopeId", "providerType");
-- AddForeignKey
ALTER TABLE "IiosProviderCredential" ADD CONSTRAINT "IiosProviderCredential_scopeId_fkey" FOREIGN KEY ("scopeId") REFERENCES "IiosScope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,20 @@
-- AlterTable
ALTER TABLE "IiosClientRegistry" ALTER COLUMN "allowedAppIds" DROP DEFAULT;
-- CreateTable
CREATE TABLE "IiosMediaObject" (
"id" TEXT NOT NULL,
"scopeId" TEXT NOT NULL,
"objectKey" TEXT NOT NULL,
"mime" TEXT NOT NULL,
"sizeBytes" BIGINT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "IiosMediaObject_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "IiosMediaObject_objectKey_key" ON "IiosMediaObject"("objectKey");
-- CreateIndex
CREATE INDEX "IiosMediaObject_createdAt_idx" ON "IiosMediaObject"("createdAt");
@@ -317,10 +317,33 @@ model IiosScope {
callbacks IiosCallbackRequest[] callbacks IiosCallbackRequest[]
notificationSubscriptions IiosNotificationSubscription[] notificationSubscriptions IiosNotificationSubscription[]
messageTemplates IiosMessageTemplate[] messageTemplates IiosMessageTemplate[]
providerCredentials IiosProviderCredential[]
@@index([orgId, appId, tenantId]) @@index([orgId, appId, tenantId])
} }
/// A tenant's own credentials for an egress provider (BYO: Twilio SMS, own SMTP, …).
/// The secret is sealed with AES-256-GCM under the platform key (IIOS_CRED_KEY); only
/// `displayHints` (non-secret, e.g. from-number / SID last-4) is ever read back out.
/// One row per (scope, providerType). Resolved at send time by the channel's provider.
model IiosProviderCredential {
id String @id @default(cuid())
scopeId String
scope IiosScope @relation(fields: [scopeId], references: [id], onDelete: Cascade)
providerType String // TWILIO_SMS | SMTP | WHATSAPP_CLOUD | …
cipherText String // base64(AES-256-GCM ciphertext of the JSON config)
iv String // base64 nonce
authTag String // base64 GCM auth tag
keyVersion Int @default(1)
displayHints Json? // non-secret display fields (fromNumber, sidLast4, …)
enabled Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([scopeId, providerType])
@@index([scopeId])
}
/// A channel-specific handle (phone/email/portal user). NOT identity — stays /// A channel-specific handle (phone/email/portal user). NOT identity — stays
/// UNVERIFIED until MDM resolves it; no silent merge. /// UNVERIFIED until MDM resolves it; no silent merge.
model IiosSourceHandle { model IiosSourceHandle {
@@ -520,6 +543,22 @@ model IiosMessagePart {
@@unique([interactionId, partIndex]) @@unique([interactionId, partIndex])
} }
/// A stored media object (bytes behind the StoragePort). A row is recorded when bytes actually
/// land (MediaService.put), so an orphan sweep can reap objects that no message part references —
/// e.g. a file attached in a composer but never sent. "Referenced" is derived at sweep time from
/// IiosMessagePart.contentRef (no stored flag to drift), after a grace period so in-progress
/// composes are never reaped.
model IiosMediaObject {
id String @id @default(cuid())
scopeId String
objectKey String @unique
mime String
sizeBytes BigInt
createdAt DateTime @default(now())
@@index([createdAt])
}
/// Transactional outbox — written in the same tx as the business row. /// Transactional outbox — written in the same tx as the business row.
model IiosOutboxEvent { model IiosOutboxEvent {
eventId String @id @default(cuid()) eventId String @id @default(cuid())
+2
View File
@@ -10,6 +10,7 @@ import { OutboxModule } from './outbox/outbox.module';
import { ThreadsModule } from './threads/threads.module'; import { ThreadsModule } from './threads/threads.module';
import { MessageModule } from './messaging/message.module'; import { MessageModule } from './messaging/message.module';
import { InboxModule } from './inbox/inbox.module'; import { InboxModule } from './inbox/inbox.module';
import { SearchModule } from './search/search.module';
import { TemplateModule } from './templates/template.module'; import { TemplateModule } from './templates/template.module';
import { MailModule } from './mail/mail.module'; import { MailModule } from './mail/mail.module';
import { MediaModule } from './media/media.module'; import { MediaModule } from './media/media.module';
@@ -39,6 +40,7 @@ import { DevController } from './dev/dev.controller';
ThreadsModule, ThreadsModule,
MessageModule, MessageModule,
InboxModule, InboxModule,
SearchModule,
TemplateModule, TemplateModule,
MailModule, MailModule,
MediaModule, MediaModule,
@@ -2,15 +2,20 @@ import { Module } from '@nestjs/common';
import { MediaModule } from '../media/media.module'; import { MediaModule } from '../media/media.module';
import { CapabilityProviderRegistry } from './capability.registry'; import { CapabilityProviderRegistry } from './capability.registry';
import { CapabilityBroker } from './capability.broker'; import { CapabilityBroker } from './capability.broker';
import { ProviderCredentialService } from './provider-credential.service';
import { ProviderCredentialController } from './provider-credential.controller';
/** /**
* The governed egress boundary (P9 slice 1). Exports the broker + registry so the * The governed egress boundary (P9 slice 1). Exports the broker + registry so the
* outbound layer routes all sends through policy + obligations + a pluggable * outbound layer routes all sends through policy + obligations + a pluggable
* provider. PLATFORM_PORTS (for the opa gate) comes from the @Global PlatformModule. * provider. PLATFORM_PORTS (for the opa gate) comes from the @Global PlatformModule.
* Also owns per-scope BYO provider credentials (Twilio SMS, ) sealed at rest and
* resolved by the channel providers at send time.
*/ */
@Module({ @Module({
imports: [MediaModule], imports: [MediaModule],
providers: [CapabilityProviderRegistry, CapabilityBroker], controllers: [ProviderCredentialController],
exports: [CapabilityBroker, CapabilityProviderRegistry], providers: [CapabilityProviderRegistry, CapabilityBroker, ProviderCredentialService],
exports: [CapabilityBroker, CapabilityProviderRegistry, ProviderCredentialService],
}) })
export class CapabilityModule {} export class CapabilityModule {}
@@ -3,7 +3,10 @@ import type { CapabilityProvider } from '@insignia/iios-contracts';
import { SandboxProvider } from './sandbox.provider'; import { SandboxProvider } from './sandbox.provider';
import { HttpProvider } from './http.provider'; import { HttpProvider } from './http.provider';
import { EmailProvider } from './email.provider'; import { EmailProvider } from './email.provider';
import { SmtpProvider, smtpFallbackFromEnv, smtpIdentityFromEnv, storageResolver } from './smtp.provider'; import { SmtpProvider, smtpFallbackFromEnv, smtpIdentityFromConfig, smtpIdentityFromEnv, storageResolver } from './smtp.provider';
import { TwilioSmsProvider, type TwilioCreds } from './twilio-sms.provider';
import { credKeyFromEnv } from './secret-crypto';
import { ProviderCredentialService } from './provider-credential.service';
import { STORAGE_PORT, type StoragePort } from '../media/storage.port'; import { STORAGE_PORT, type StoragePort } from '../media/storage.port';
const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'SMS', 'WHATSAPP', 'PORTAL']; const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'SMS', 'WHATSAPP', 'PORTAL'];
@@ -21,7 +24,10 @@ const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'SMS', 'WHATSAPP', 'PORTAL'];
export class CapabilityProviderRegistry { export class CapabilityProviderRegistry {
private readonly byChannel = new Map<string, CapabilityProvider>(); private readonly byChannel = new Map<string, CapabilityProvider>();
constructor(@Optional() @Inject(STORAGE_PORT) private readonly storage?: StoragePort) { constructor(
@Optional() @Inject(STORAGE_PORT) private readonly storage?: StoragePort,
@Optional() private readonly credentials?: ProviderCredentialService,
) {
for (const ch of DEFAULT_CHANNELS) this.register(new SandboxProvider([ch])); for (const ch of DEFAULT_CHANNELS) this.register(new SandboxProvider([ch]));
for (const ch of DEFAULT_CHANNELS) { for (const ch of DEFAULT_CHANNELS) {
const url = process.env[`IIOS_PROVIDER_URL_${ch}`]; const url = process.env[`IIOS_PROVIDER_URL_${ch}`];
@@ -30,11 +36,21 @@ export class CapabilityProviderRegistry {
this.register(ch === 'EMAIL' ? new EmailProvider(url) : new HttpProvider(ch, url)); this.register(ch === 'EMAIL' ? new EmailProvider(url) : new HttpProvider(ch, url));
} }
// Real SMTP for EMAIL wins over the HTTP relay when configured (registered last). Attachments // Real SMTP for EMAIL wins over the HTTP relay when configured (registered last). Attachments
// resolve through the media StoragePort when one is bound (else attachments FAIL closed). // resolve through the media StoragePort when one is bound (else attachments FAIL closed). A
// tenant's own SMTP (BYO) is resolved per scope at send time and takes precedence over the env
// identity; register the provider whenever EITHER the env SMTP or the credential store exists.
const smtp = smtpIdentityFromEnv(); const smtp = smtpIdentityFromEnv();
if (smtp) { const smtpCreds = credKeyFromEnv() && this.credentials ? this.credentials : undefined;
if (smtp || smtpCreds) {
const resolver = this.storage ? storageResolver(this.storage) : undefined; const resolver = this.storage ? storageResolver(this.storage) : undefined;
this.register(new SmtpProvider(smtp, smtpFallbackFromEnv() ?? undefined, undefined, resolver)); const credResolver = smtpCreds ? (scopeId: string) => smtpCreds.resolve(scopeId, 'SMTP').then(smtpIdentityFromConfig) : undefined;
this.register(new SmtpProvider(smtp ?? undefined, smtpFallbackFromEnv() ?? undefined, undefined, resolver, credResolver));
}
// BYO SMS via each tenant's own Twilio creds (resolved per scope at send time). Registered
// only when the platform key + credential store are present — else SMS stays on the sandbox.
if (credKeyFromEnv() && this.credentials) {
const creds = this.credentials;
this.register(new TwilioSmsProvider((scopeId) => creds.resolve(scopeId, 'TWILIO_SMS') as Promise<TwilioCreds | null>));
} }
} }
@@ -0,0 +1,76 @@
import { BadRequestException, Body, Controller, Get, Headers, Param, Put } from '@nestjs/common';
import { SessionVerifier } from '../platform/session.verifier';
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
import { ProviderCredentialService } from './provider-credential.service';
const SUPPORTED = new Set(['TWILIO_SMS', 'SMTP']);
const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : '');
const isEmail = (v: string): boolean => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v);
/** Validate + shape the credential config for a provider type. Throws 400 on bad input.
* be-crm does the strict client-facing validation; this is IIOS's own guard. */
function buildConfig(providerType: string, body: Record<string, unknown>): Record<string, unknown> {
if (providerType === 'TWILIO_SMS') {
const accountSid = str(body.accountSid);
const authToken = str(body.authToken);
const fromNumber = str(body.fromNumber);
if (!accountSid || !authToken || !fromNumber) throw new BadRequestException('accountSid, authToken and fromNumber are required');
return { accountSid, authToken, fromNumber };
}
if (providerType === 'SMTP') {
const host = str(body.host);
const user = str(body.user);
const pass = str(body.pass);
const fromEmail = str(body.fromEmail);
const fromName = str(body.fromName);
if (!host || !user || !pass || !fromEmail) throw new BadRequestException('host, user, pass and fromEmail are required');
if (!isEmail(fromEmail)) throw new BadRequestException('fromEmail must be a valid email');
return { host, port: Number(body.port) || 587, secure: body.secure === true || body.secure === 'true', user, pass, fromEmail, ...(fromName ? { fromName } : {}) };
}
throw new BadRequestException(`unsupported providerType: ${providerType}`);
}
/**
* Per-scope BYO integration credentials. The caller's attested session decides the scope, so a
* tenant can only read/write ITS OWN provider credentials. The secret is sealed by the service;
* GET returns a masked status (never the token). be-crm gates this behind tenant-admin policy.
*/
@Controller('v1/providers')
export class ProviderCredentialController {
constructor(
private readonly session: SessionVerifier,
private readonly actors: ActorResolver,
private readonly credentials: ProviderCredentialService,
) {}
@Put(':providerType/credentials')
async put(
@Param('providerType') providerType: string,
@Body() body: Record<string, unknown>,
@Headers('authorization') authorization?: string,
) {
this.assertSupported(providerType);
const config = buildConfig(providerType, body ?? {});
const scope = await this.actors.resolveScope(this.principal(authorization));
await this.credentials.upsert(scope.id, providerType, config);
return this.credentials.status(scope.id, providerType);
}
@Get(':providerType/credentials')
async get(@Param('providerType') providerType: string, @Headers('authorization') authorization?: string) {
this.assertSupported(providerType);
const scope = await this.actors.resolveScope(this.principal(authorization));
return this.credentials.status(scope.id, providerType);
}
private assertSupported(providerType: string): void {
if (!SUPPORTED.has(providerType)) throw new BadRequestException(`unsupported providerType: ${providerType}`);
}
private principal(authorization?: string): MessagePrincipal {
const token = (authorization ?? '').replace(/^Bearer\s+/i, '');
if (!token) throw new BadRequestException('Authorization bearer token is required');
return this.session.verify(token);
}
}
@@ -0,0 +1,79 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { ProviderCredentialService } from './provider-credential.service';
const KEY_B64 = Buffer.alloc(32, 7).toString('base64');
/** Minimal in-memory stand-in for prisma.iiosProviderCredential (upsert/findUnique). */
function fakePrisma() {
const rows = new Map<string, Record<string, unknown>>();
const k = (scopeId: string, providerType: string) => `${scopeId}::${providerType}`;
return {
_rows: rows,
iiosProviderCredential: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async upsert({ where, create, update }: any) {
const key = k(where.scopeId_providerType.scopeId, where.scopeId_providerType.providerType);
const existing = rows.get(key);
const row = existing ? { ...existing, ...update } : { ...create };
rows.set(key, row);
return row;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async findUnique({ where }: any) {
return rows.get(k(where.scopeId_providerType.scopeId, where.scopeId_providerType.providerType)) ?? null;
},
},
};
}
function make(prisma: ReturnType<typeof fakePrisma>, env: NodeJS.ProcessEnv): ProviderCredentialService {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const s = new ProviderCredentialService(prisma as any);
s.env = env;
return s;
}
describe('ProviderCredentialService', () => {
let prisma: ReturnType<typeof fakePrisma>;
let svc: ProviderCredentialService;
beforeEach(() => {
prisma = fakePrisma();
svc = make(prisma, { IIOS_CRED_KEY: KEY_B64 } as NodeJS.ProcessEnv);
});
it('seals the secret at rest — plaintext token never stored', async () => {
await svc.upsert('scope_1', 'TWILIO_SMS', { accountSid: 'AC123', authToken: 'tok_secret', fromNumber: '+15550001111' });
const stored = JSON.stringify([...prisma._rows.values()]);
expect(stored).not.toContain('tok_secret');
expect(stored).toContain('cipherText');
});
it('resolves back the exact config it sealed', async () => {
const cfg = { accountSid: 'AC123', authToken: 'tok_secret', fromNumber: '+15550001111' };
await svc.upsert('scope_1', 'TWILIO_SMS', cfg);
expect(await svc.resolve('scope_1', 'TWILIO_SMS')).toEqual(cfg);
});
it('status is masked — reveals hints, never the token', async () => {
await svc.upsert('scope_1', 'TWILIO_SMS', { accountSid: 'AC1234567', authToken: 'tok_secret', fromNumber: '+15550001111' });
const status = await svc.status('scope_1', 'TWILIO_SMS');
expect(status).toMatchObject({ configured: true, enabled: true, hints: { fromNumber: '+15550001111', sidLast4: '4567' } });
expect(JSON.stringify(status)).not.toContain('tok_secret');
});
it('reports not-configured for an unknown scope', async () => {
expect(await svc.status('nope', 'TWILIO_SMS')).toEqual({ configured: false });
expect(await svc.resolve('nope', 'TWILIO_SMS')).toBeNull();
});
it('does not resolve a disabled credential', async () => {
await svc.upsert('scope_1', 'TWILIO_SMS', { accountSid: 'AC123', authToken: 't', fromNumber: '+1' }, { enabled: false });
expect(await svc.resolve('scope_1', 'TWILIO_SMS')).toBeNull();
});
it('throws when the platform key is absent (fail closed, never store plaintext)', async () => {
const noKey = make(prisma, {} as NodeJS.ProcessEnv);
await expect(noKey.upsert('s', 'TWILIO_SMS', { accountSid: 'A', authToken: 't', fromNumber: '+1' })).rejects.toThrow(/IIOS_CRED_KEY/);
});
});
@@ -0,0 +1,73 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { credKeyFromEnv, sealSecret, openSecret, type SealedSecret } from './secret-crypto';
/** A provider config is an opaque JSON bag; each provider knows its own shape. */
export type ProviderConfig = Record<string, unknown>;
export interface CredentialStatus {
configured: boolean;
enabled?: boolean;
hints?: Record<string, unknown>;
}
/** Non-secret display fields surfaced by `status` — NEVER the secret itself. */
function hintsFor(providerType: string, config: ProviderConfig): Record<string, unknown> {
if (providerType === 'TWILIO_SMS') {
const sid = String(config.accountSid ?? '');
return { fromNumber: config.fromNumber ?? null, sidLast4: sid.slice(-4) };
}
if (providerType === 'SMTP') {
return { host: config.host ?? null, port: config.port ?? null, user: config.user ?? null, fromEmail: config.fromEmail ?? null, fromName: config.fromName ?? null };
}
return {};
}
/**
* The tenant's BYO integration credentials, one row per (scope, providerType). The secret is
* sealed with AES-256-GCM under the platform key before it touches the DB; only non-secret
* `displayHints` are ever read back. `resolve` decrypts on demand at send time; `status` never
* returns the secret. Fail-closed: no platform key upsert throws (we refuse to store plaintext).
*/
@Injectable()
export class ProviderCredentialService {
/** Overridable in tests; defaults to the process env (holds IIOS_CRED_KEY). */
env: NodeJS.ProcessEnv = process.env;
constructor(private readonly prisma: PrismaService) {}
async upsert(scopeId: string, providerType: string, config: ProviderConfig, opts?: { enabled?: boolean }): Promise<void> {
const key = credKeyFromEnv(this.env);
if (!key) throw new BadRequestException('IIOS_CRED_KEY is not configured — cannot store integration credentials');
const sealed = sealSecret(JSON.stringify(config), key);
const enabled = opts?.enabled ?? true;
const displayHints = hintsFor(providerType, config) as Prisma.InputJsonValue;
await this.prisma.iiosProviderCredential.upsert({
where: { scopeId_providerType: { scopeId, providerType } },
create: { scopeId, providerType, ...sealed, displayHints, enabled },
update: { ...sealed, displayHints, enabled },
});
}
/** Decrypt the config for send-time use. Null when unconfigured, disabled, or no key. */
async resolve(scopeId: string, providerType: string): Promise<ProviderConfig | null> {
const row = await this.prisma.iiosProviderCredential.findUnique({
where: { scopeId_providerType: { scopeId, providerType } },
});
if (!row || !row.enabled) return null;
const key = credKeyFromEnv(this.env);
if (!key) return null;
const sealed: SealedSecret = { cipherText: row.cipherText, iv: row.iv, authTag: row.authTag, keyVersion: row.keyVersion };
return JSON.parse(openSecret(sealed, key)) as ProviderConfig;
}
/** Masked view for the admin UI — configured + hints, never the secret. */
async status(scopeId: string, providerType: string): Promise<CredentialStatus> {
const row = await this.prisma.iiosProviderCredential.findUnique({
where: { scopeId_providerType: { scopeId, providerType } },
});
if (!row) return { configured: false };
return { configured: true, enabled: row.enabled, hints: (row.displayHints ?? {}) as Record<string, unknown> };
}
}
@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { credKeyFromEnv, sealSecret, openSecret, SECRET_KEY_VERSION } from './secret-crypto';
const KEY = Buffer.alloc(32, 7); // deterministic 32-byte key for tests
describe('secret-crypto', () => {
it('round-trips a secret through seal → open', () => {
const sealed = sealSecret('sk_live_abc123', KEY);
expect(sealed.keyVersion).toBe(SECRET_KEY_VERSION);
expect(sealed.cipherText).not.toContain('sk_live_abc123');
expect(openSecret(sealed, KEY)).toBe('sk_live_abc123');
});
it('produces a distinct ciphertext each time (random IV)', () => {
const a = sealSecret('same', KEY);
const b = sealSecret('same', KEY);
expect(a.cipherText).not.toBe(b.cipherText);
expect(a.iv).not.toBe(b.iv);
});
it('fails to open with the wrong key', () => {
const sealed = sealSecret('top-secret', KEY);
expect(() => openSecret(sealed, Buffer.alloc(32, 9))).toThrow();
});
it('fails to open if the ciphertext is tampered (GCM auth)', () => {
const sealed = sealSecret('top-secret', KEY);
const bad = { ...sealed, cipherText: Buffer.from('deadbeef', 'hex').toString('base64') };
expect(() => openSecret(bad, KEY)).toThrow();
});
it('credKeyFromEnv returns null when unset and rejects a wrong-length key', () => {
expect(credKeyFromEnv({})).toBeNull();
expect(() => credKeyFromEnv({ IIOS_CRED_KEY: Buffer.alloc(16).toString('base64') })).toThrow(/32 bytes/);
const key = credKeyFromEnv({ IIOS_CRED_KEY: KEY.toString('base64') });
expect(key?.length).toBe(32);
});
});
@@ -0,0 +1,46 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
/**
* Envelope for a tenant secret sealed with AES-256-GCM. Stored as base64 columns on
* IiosProviderCredential; the plaintext (a provider config JSON string) never touches disk.
*/
export interface SealedSecret {
cipherText: string;
iv: string;
authTag: string;
keyVersion: number;
}
/** Bumped only when the platform master key rotates; lets old rows decrypt under an old key. */
export const SECRET_KEY_VERSION = 1;
/**
* Resolve the 32-byte platform master key from `IIOS_CRED_KEY` (base64). Returns null when
* unset (so egress providers that need it stay unregistered / fail closed) but THROWS on a
* present-but-malformed key a wrong-length key is an operator error, not a "disabled" state.
*/
export function credKeyFromEnv(env: NodeJS.ProcessEnv = process.env): Buffer | null {
const raw = env.IIOS_CRED_KEY;
if (!raw) return null;
const key = Buffer.from(raw, 'base64');
if (key.length !== 32) throw new Error('IIOS_CRED_KEY must decode to 32 bytes (base64-encoded 256-bit key)');
return key;
}
export function sealSecret(plaintext: string, key: Buffer): SealedSecret {
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, iv);
const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
return {
cipherText: enc.toString('base64'),
iv: iv.toString('base64'),
authTag: cipher.getAuthTag().toString('base64'),
keyVersion: SECRET_KEY_VERSION,
};
}
export function openSecret(sealed: SealedSecret, key: Buffer): string {
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(sealed.iv, 'base64'));
decipher.setAuthTag(Buffer.from(sealed.authTag, 'base64'));
return Buffer.concat([decipher.update(Buffer.from(sealed.cipherText, 'base64')), decipher.final()]).toString('utf8');
}
@@ -133,3 +133,42 @@ describe('storageResolver (media StoragePort → AttachmentResolver)', () => {
expect(await r('nope')).toBeNull(); expect(await r('nope')).toBeNull();
}); });
}); });
describe('BYO SMTP (scope-aware)', () => {
const TENANT: SmtpIdentity = { host: 'smtp.acme.com', port: 465, secure: true, user: 'apikey', pass: 't', from: 'Acme <no-reply@acme.com>' };
const scoped = (payload: Record<string, unknown>, scopeId?: string): CapabilityRequest => ({
capability: 'channel.send', channelType: 'EMAIL', target: 'dana@acme.com', payload, idempotencyKey: 'k1', ...(scopeId ? { scopeId } : {}),
});
it('sends from the tenant identity when a scope has its own SMTP', async () => {
const s = stub();
const p = new SmtpProvider(ID, FB, s.make, undefined, async (sid) => (sid === 'scope_1' ? TENANT : null));
const res = await p.send(scoped({ subject: 'Hi', text: 'x' }, 'scope_1'));
expect(res.outcome).toBe('SENT');
expect(s.calls[0]!.id).toEqual(TENANT);
expect(s.calls[0]!.mail.from).toBe('Acme <no-reply@acme.com>');
});
it('falls back to the platform identity when the scope has no SMTP', async () => {
const s = stub();
const p = new SmtpProvider(ID, FB, s.make, undefined, async () => null);
await p.send(scoped({ subject: 'Hi', text: 'x' }, 'scope_2'));
expect(s.calls[0]!.id).toEqual(ID);
});
it('fails closed (NOT_CONFIGURED) when there is neither a tenant nor a platform identity', async () => {
const s = stub();
const p = new SmtpProvider(undefined, undefined, s.make, undefined, async () => null);
const res = await p.send(scoped({ subject: 'Hi', text: 'x' }, 'scope_3'));
expect(res.outcome).toBe('FAILED');
expect(res.errorCode).toBe('NOT_CONFIGURED');
expect(s.calls).toHaveLength(0);
});
it('smtpIdentityFromConfig maps a stored config (fromName + email → from)', async () => {
const { smtpIdentityFromConfig } = await import('./smtp.provider');
expect(smtpIdentityFromConfig({ host: 'h', port: 465, secure: true, user: 'u', pass: 'p', fromEmail: 'a@b.com', fromName: 'Acme' }))
.toEqual({ host: 'h', port: 465, secure: true, user: 'u', pass: 'p', from: 'Acme <a@b.com>' });
expect(smtpIdentityFromConfig({ host: 'h', user: 'u', pass: 'p' })).toBeNull();
});
});
@@ -84,6 +84,29 @@ function identityFrom(env: NodeJS.ProcessEnv, prefix: string): SmtpIdentity | nu
}; };
} }
/** Map a tenant's stored SMTP config (BYO) to a sending identity, or null if incomplete. */
export function smtpIdentityFromConfig(c: Record<string, unknown> | null | undefined): SmtpIdentity | null {
if (!c) return null;
const s = (v: unknown): string => (typeof v === 'string' ? v.trim() : '');
const host = s(c.host);
const user = s(c.user);
const pass = s(c.pass);
const fromEmail = s(c.fromEmail);
if (!host || !user || !pass || !fromEmail) return null;
const fromName = s(c.fromName);
return {
host,
port: Number(c.port) || 587,
secure: c.secure === true || c.secure === 'true',
user,
pass,
from: fromName ? `${fromName} <${fromEmail}>` : fromEmail,
};
}
/** Resolve a scope's own SMTP identity (BYO), decrypting the stored credential. Null when unset. */
export type SmtpCredResolver = (scopeId: string) => Promise<SmtpIdentity | null>;
interface EmailPayload { interface EmailPayload {
subject?: string; subject?: string;
text?: string; text?: string;
@@ -107,10 +130,12 @@ export class SmtpProvider implements CapabilityProvider {
private readonly makeTransport: (id: SmtpIdentity) => MailTransport; private readonly makeTransport: (id: SmtpIdentity) => MailTransport;
constructor( constructor(
private readonly primary: SmtpIdentity, private readonly primary: SmtpIdentity | undefined,
private readonly fallback?: SmtpIdentity, private readonly fallback?: SmtpIdentity,
makeTransport?: (id: SmtpIdentity) => MailTransport, makeTransport?: (id: SmtpIdentity) => MailTransport,
private readonly resolveAttachment?: AttachmentResolver, private readonly resolveAttachment?: AttachmentResolver,
/** BYO: resolve the tenant's own SMTP identity per scope; used before the env identity. */
private readonly credResolver?: SmtpCredResolver,
) { ) {
this.makeTransport = makeTransport ?? defaultTransport; this.makeTransport = makeTransport ?? defaultTransport;
} }
@@ -119,6 +144,13 @@ export class SmtpProvider implements CapabilityProvider {
const started = Date.now(); const started = Date.now();
const p = (req.payload ?? {}) as EmailPayload; const p = (req.payload ?? {}) as EmailPayload;
// BYO SMTP: a tenant's own identity wins over the platform env identity. The env fallback
// (accounts@ → ceo@) applies ONLY to the platform identity, never to a tenant's own server.
const tenant = req.scopeId && this.credResolver ? await this.credResolver(req.scopeId).catch(() => null) : null;
const identity = tenant ?? this.primary;
if (!identity) return this.failed('NOT_CONFIGURED', started);
const fallback = tenant ? undefined : this.fallback;
// Resolve attachment bytes up front. Fail CLOSED — never send an invoice/receipt email missing // Resolve attachment bytes up front. Fail CLOSED — never send an invoice/receipt email missing
// its file; a FAILED command retries instead. Resolved once so a fallback retry doesn't re-fetch. // its file; a FAILED command retries instead. Resolved once so a fallback retry doesn't re-fetch.
let attachments: MailAttachment[] | undefined; let attachments: MailAttachment[] | undefined;
@@ -145,13 +177,13 @@ export class SmtpProvider implements CapabilityProvider {
}); });
try { try {
const info = await attempt(this.primary); const info = await attempt(identity);
return { providerRef: info.messageId, outcome: 'SENT', latencyMs: Date.now() - started }; return { providerRef: info.messageId, outcome: 'SENT', latencyMs: Date.now() - started };
} catch (err) { } catch (err) {
// Retry via the fallback identity ONLY if the primary never got the message accepted. // Retry via the fallback identity ONLY if the primary never got the message accepted.
if (this.fallback && isPreAcceptanceFailure(err)) { if (fallback && isPreAcceptanceFailure(err)) {
try { try {
const info = await attempt(this.fallback); const info = await attempt(fallback);
return { providerRef: `fallback:${info.messageId}`, outcome: 'SENT', latencyMs: Date.now() - started }; return { providerRef: `fallback:${info.messageId}`, outcome: 'SENT', latencyMs: Date.now() - started };
} catch (err2) { } catch (err2) {
return { providerRef: `smtp-error-${randomUUID().slice(0, 8)}`, outcome: 'FAILED', errorCode: codeOf(err2), latencyMs: Date.now() - started }; return { providerRef: `smtp-error-${randomUUID().slice(0, 8)}`, outcome: 'FAILED', errorCode: codeOf(err2), latencyMs: Date.now() - started };
@@ -0,0 +1,67 @@
import { describe, it, expect, vi } from 'vitest';
import type { CapabilityRequest } from '@insignia/iios-contracts';
import { TwilioSmsProvider, type TwilioCreds } from './twilio-sms.provider';
const CREDS: TwilioCreds = { accountSid: 'AC123', authToken: 'tok_secret', fromNumber: '+15550001111' };
const req = (over: Partial<CapabilityRequest> = {}): CapabilityRequest => ({
capability: 'channel.send',
channelType: 'SMS',
scopeId: 'scope_1',
target: '+15557654321',
payload: { text: 'hello world' },
idempotencyKey: 'idem-1',
...over,
});
describe('TwilioSmsProvider', () => {
it('resolves the scope creds and POSTs to Twilio, returning SENT with the message SID', async () => {
const http = vi.fn().mockResolvedValue({ ok: true, status: 201, json: async () => ({ sid: 'SM999' }), text: async () => '' });
const resolve = vi.fn().mockResolvedValue(CREDS);
const p = new TwilioSmsProvider(resolve, http);
const res = await p.send(req());
expect(resolve).toHaveBeenCalledWith('scope_1');
const [url, init] = http.mock.calls[0];
expect(url).toBe('https://api.twilio.com/2010-04-01/Accounts/AC123/Messages.json');
expect(init.method).toBe('POST');
expect(init.headers.authorization).toBe(`Basic ${Buffer.from('AC123:tok_secret').toString('base64')}`);
const body = new URLSearchParams(init.body as string);
expect(body.get('To')).toBe('+15557654321');
expect(body.get('From')).toBe('+15550001111');
expect(body.get('Body')).toBe('hello world');
expect(res).toMatchObject({ outcome: 'SENT', providerRef: 'SM999' });
});
it('fails closed as NOT_CONFIGURED when the scope has no creds', async () => {
const http = vi.fn();
const p = new TwilioSmsProvider(async () => null, http);
const res = await p.send(req());
expect(res.outcome).toBe('FAILED');
expect(res.errorCode).toBe('NOT_CONFIGURED');
expect(http).not.toHaveBeenCalled();
});
it('fails closed as NOT_CONFIGURED when the request has no scope', async () => {
const p = new TwilioSmsProvider(async () => CREDS, vi.fn());
const res = await p.send(req({ scopeId: undefined }));
expect(res.outcome).toBe('FAILED');
expect(res.errorCode).toBe('NOT_CONFIGURED');
});
it('surfaces a Twilio API error as FAILED (never throws)', async () => {
const http = vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({ code: 20003, message: 'Authenticate' }), text: async () => 'Authenticate' });
const p = new TwilioSmsProvider(async () => CREDS, http);
const res = await p.send(req());
expect(res.outcome).toBe('FAILED');
expect(res.errorCode).toBe('TWILIO_20003');
});
it('surfaces a transport throw as FAILED', async () => {
const http = vi.fn().mockRejectedValue(new Error('network down'));
const p = new TwilioSmsProvider(async () => CREDS, http);
const res = await p.send(req());
expect(res.outcome).toBe('FAILED');
});
});
@@ -0,0 +1,70 @@
import type { CapabilityProvider, CapabilityRequest, ProviderResult } from '@insignia/iios-contracts';
/** The decrypted Twilio config a tenant configures (BYO credentials). */
export interface TwilioCreds {
accountSid: string;
authToken: string;
fromNumber: string;
}
/** Resolves a scope's Twilio creds (decrypting on demand); null when unconfigured/disabled. */
export type TwilioCredResolver = (scopeId: string) => Promise<TwilioCreds | null>;
/** Injectable HTTP sender so tests don't hit the network; prod uses fetch. */
export type HttpSender = (url: string, init: { method: string; headers: Record<string, string>; body: string }) => Promise<{
ok: boolean;
status: number;
json: () => Promise<unknown>;
text: () => Promise<string>;
}>;
const defaultSender: HttpSender = (url, init) => fetch(url, init) as unknown as ReturnType<HttpSender>;
/**
* SMS egress via Twilio, using the CALLER'S OWN credentials (resolved per scope at send time).
* Fail-closed: no scope or no configured creds FAILED/NOT_CONFIGURED, nothing sent. A Twilio
* API error is surfaced as FAILED (never thrown), per the provider doctrine.
*/
export class TwilioSmsProvider implements CapabilityProvider {
readonly name = 'twilio-sms';
readonly channelTypes = ['SMS'];
readonly capabilities = { canSend: true, maxPayloadBytes: 1600 };
constructor(
private readonly resolve: TwilioCredResolver,
private readonly http: HttpSender = defaultSender,
) {}
async send(req: CapabilityRequest): Promise<ProviderResult> {
const started = Date.now();
if (!req.scopeId) return { providerRef: 'unconfigured', outcome: 'FAILED', errorCode: 'NOT_CONFIGURED' };
const creds = await this.resolve(req.scopeId).catch(() => null);
if (!creds) return { providerRef: 'unconfigured', outcome: 'FAILED', errorCode: 'NOT_CONFIGURED' };
const body = new URLSearchParams({
To: req.target,
From: creds.fromNumber,
Body: String(req.payload.text ?? req.payload.body ?? ''),
}).toString();
try {
const res = await this.http(`https://api.twilio.com/2010-04-01/Accounts/${creds.accountSid}/Messages.json`, {
method: 'POST',
headers: {
authorization: `Basic ${Buffer.from(`${creds.accountSid}:${creds.authToken}`).toString('base64')}`,
'content-type': 'application/x-www-form-urlencoded',
},
body,
});
const latencyMs = Date.now() - started;
const payload = (await res.json().catch(() => ({}))) as { sid?: string; code?: number; message?: string };
if (!res.ok) {
return { providerRef: `twilio-${res.status}`, outcome: 'FAILED', errorCode: payload.code ? `TWILIO_${payload.code}` : `HTTP_${res.status}`, latencyMs };
}
return { providerRef: payload.sid ?? 'twilio-sent', outcome: 'SENT', latencyMs };
} catch (err) {
return { providerRef: 'twilio-error', outcome: 'FAILED', errorCode: (err as Error).message.slice(0, 60), latencyMs: Date.now() - started };
}
}
}
@@ -1,6 +1,7 @@
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { import {
IsArray, IsArray,
IsInt,
IsISO8601, IsISO8601,
IsObject, IsObject,
IsOptional, IsOptional,
@@ -40,6 +41,7 @@ class PartDto {
@IsOptional() @IsString() bodyText?: string; @IsOptional() @IsString() bodyText?: string;
@IsOptional() @IsString() contentRef?: string; @IsOptional() @IsString() contentRef?: string;
@IsOptional() @IsString() mimeType?: string; @IsOptional() @IsString() mimeType?: string;
@IsOptional() @IsInt() sizeBytes?: number;
} }
/** Validates the POST /v1/interactions/ingest body (conforms to IngestInteractionRequest). */ /** Validates the POST /v1/interactions/ingest body (conforms to IngestInteractionRequest). */
@@ -178,6 +178,7 @@ export class IngestService {
bodyText: p.bodyText, bodyText: p.bodyText,
contentRef: p.contentRef, contentRef: p.contentRef,
mimeType: p.mimeType, mimeType: p.mimeType,
sizeBytes: p.sizeBytes != null ? BigInt(p.sizeBytes) : undefined,
})), })),
}); });
@@ -22,6 +22,7 @@ export class MailController {
vars: body.vars ?? {}, vars: body.vars ?? {},
...(body.locale ? { locale: body.locale } : {}), ...(body.locale ? { locale: body.locale } : {}),
idempotencyKey: body.idempotencyKey, idempotencyKey: body.idempotencyKey,
...(body.attachments && body.attachments.length > 0 ? { attachments: body.attachments } : {}),
}); });
} }
@@ -7,6 +7,7 @@ export class AttachmentDto {
@IsOptional() @IsString() filename?: string; @IsOptional() @IsString() filename?: string;
@IsString() @IsNotEmpty() contentRef!: string; @IsString() @IsNotEmpty() contentRef!: string;
@IsOptional() @IsString() mimeType?: string; @IsOptional() @IsString() mimeType?: string;
@IsOptional() @IsInt() sizeBytes?: number;
} }
/** POST /v1/mail/internal — app-to-app mail (no SMTP). Provide EITHER `key` OR `inline`. */ /** POST /v1/mail/internal — app-to-app mail (no SMTP). Provide EITHER `key` OR `inline`. */
@@ -19,6 +20,8 @@ export class MailInternalDto {
@IsOptional() @IsObject() vars?: Record<string, unknown>; @IsOptional() @IsObject() vars?: Record<string, unknown>;
@IsOptional() @IsString() locale?: string; @IsOptional() @IsString() locale?: string;
@IsString() @IsNotEmpty() idempotencyKey!: string; @IsString() @IsNotEmpty() idempotencyKey!: string;
/** In-app attachment refs — stored as message parts so the recipient's inbox can render them. */
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => AttachmentDto) attachments?: AttachmentDto[];
} }
/** POST /v1/mail/send — external email via SMTP + optional in-app mirror for a registered recipient. */ /** POST /v1/mail/send — external email via SMTP + optional in-app mirror for a registered recipient. */
@@ -66,6 +66,20 @@ describe('MailService.postInternal', () => {
expect(aliceThreads.map((t) => t.threadId)).toContain(res.threadId); expect(aliceThreads.map((t) => t.threadId)).toContain(res.threadId);
}); });
it('stores an in-app attachment as a media part alongside the body', async () => {
const res = await mail().postInternal(alice, {
source: inline, recipientUserId: 'bob', idempotencyKey: 'int:att',
attachments: [{ filename: 'roof.png', contentRef: 'scope/roof.png', mimeType: 'image/png', sizeBytes: 2048 }],
});
const interaction = await prisma.iiosInteraction.findUniqueOrThrow({ where: { id: res.interactionId }, include: { parts: true } });
const file = interaction.parts.find((p) => p.contentRef);
expect(file).toBeDefined();
expect(file!.kind).toBe('MEDIA_REF'); // image/* → MEDIA_REF
expect(file!.contentRef).toBe('scope/roof.png');
expect(file!.mimeType).toBe('image/png');
expect(file!.sizeBytes != null ? Number(file!.sizeBytes) : null).toBe(2048);
});
it('is idempotent per key — a replay reuses the same thread, no duplicate interaction', async () => { it('is idempotent per key — a replay reuses the same thread, no duplicate interaction', async () => {
const a = await mail().postInternal(alice, { source: inline, recipientUserId: 'bob', idempotencyKey: 'int:dup' }); const a = await mail().postInternal(alice, { source: inline, recipientUserId: 'bob', idempotencyKey: 'int:dup' });
const b = await mail().postInternal(alice, { source: inline, recipientUserId: 'bob', idempotencyKey: 'int:dup' }); const b = await mail().postInternal(alice, { source: inline, recipientUserId: 'bob', idempotencyKey: 'int:dup' });
+16 -4
View File
@@ -20,12 +20,16 @@ export function contentToParts(content: RenderedContent): { subject?: string; pa
return { ...(content.subject != null ? { subject: content.subject } : {}), parts }; return { ...(content.subject != null ? { subject: content.subject } : {}), parts };
} }
export interface MailAttachment { filename?: string; contentRef: string; mimeType?: string; sizeBytes?: number }
export interface PostInternalInput { export interface PostInternalInput {
source: TemplateSource; source: TemplateSource;
recipientUserId: string; recipientUserId: string;
vars?: Record<string, unknown>; vars?: Record<string, unknown>;
locale?: string; locale?: string;
idempotencyKey: string; idempotencyKey: string;
/** In-app attachment refs — stored as FILE message parts alongside the body. */
attachments?: MailAttachment[];
} }
export interface SendExternalInput { export interface SendExternalInput {
@@ -65,7 +69,7 @@ export class MailService {
async postInternal(principal: MessagePrincipal, input: PostInternalInput): Promise<{ threadId: string; interactionId: string }> { async postInternal(principal: MessagePrincipal, input: PostInternalInput): Promise<{ threadId: string; interactionId: string }> {
const { content } = await this.templates.render(input.source, input.vars ?? {}, { channel: 'INTERNAL', ...(input.locale ? { locale: input.locale } : {}) }); const { content } = await this.templates.render(input.source, input.vars ?? {}, { channel: 'INTERNAL', ...(input.locale ? { locale: input.locale } : {}) });
return this.deposit(principal, input.recipientUserId, content, input.idempotencyKey, 'PORTAL'); return this.deposit(principal, input.recipientUserId, content, input.idempotencyKey, 'PORTAL', input.attachments);
} }
async sendExternalWithMirror(principal: MessagePrincipal, input: SendExternalInput): Promise<{ commandId: string; mirror?: { threadId: string; interactionId: string } }> { async sendExternalWithMirror(principal: MessagePrincipal, input: SendExternalInput): Promise<{ commandId: string; mirror?: { threadId: string; interactionId: string } }> {
@@ -79,13 +83,21 @@ export class MailService {
if (!input.mirrorToUserId) return { commandId: command.id }; if (!input.mirrorToUserId) return { commandId: command.id };
const { content } = await this.templates.render(input.source, input.vars ?? {}, { channel: 'EMAIL', ...(input.locale ? { locale: input.locale } : {}) }); const { content } = await this.templates.render(input.source, input.vars ?? {}, { channel: 'EMAIL', ...(input.locale ? { locale: input.locale } : {}) });
const mirror = await this.deposit(principal, input.mirrorToUserId, content, `mirror:${input.idempotencyKey}`, 'EMAIL'); const mirror = await this.deposit(principal, input.mirrorToUserId, content, `mirror:${input.idempotencyKey}`, 'EMAIL', input.attachments);
return { commandId: command.id, mirror }; return { commandId: command.id, mirror };
} }
/** A stored media ref → a message part; kind follows the mime (image/video → MEDIA_REF, audio → VOICE_REF, else FILE_REF). */
private attachmentPart(a: MailAttachment): { kind: 'MEDIA_REF' | 'VOICE_REF' | 'FILE_REF'; bodyText?: string; contentRef: string; mimeType?: string; sizeBytes?: number } {
const mime = a.mimeType ?? '';
const kind = /^(image|video)\//.test(mime) ? 'MEDIA_REF' : /^audio\//.test(mime) ? 'VOICE_REF' : 'FILE_REF';
return { kind, ...(a.filename ? { bodyText: a.filename } : {}), contentRef: a.contentRef, ...(a.mimeType ? { mimeType: a.mimeType } : {}), ...(a.sizeBytes != null ? { sizeBytes: a.sizeBytes } : {}) };
}
/** Ingest the rendered content as an EMAIL interaction on a per-email thread, visible to both parties. */ /** Ingest the rendered content as an EMAIL interaction on a per-email thread, visible to both parties. */
private async deposit(principal: MessagePrincipal, recipientUserId: string, content: RenderedContent, key: string, channelType: string): Promise<{ threadId: string; interactionId: string }> { private async deposit(principal: MessagePrincipal, recipientUserId: string, content: RenderedContent, key: string, channelType: string, attachments?: MailAttachment[]): Promise<{ threadId: string; interactionId: string }> {
const { subject, parts } = contentToParts(content); const { subject, parts } = contentToParts(content);
const allParts = [...parts, ...(attachments ?? []).map((a) => this.attachmentPart(a))];
const res = await this.ingest.ingest( const res = await this.ingest.ingest(
{ {
scope: { orgId: principal.orgId, appId: principal.appId, ...(principal.tenantId ? { tenantId: principal.tenantId } : {}) }, scope: { orgId: principal.orgId, appId: principal.appId, ...(principal.tenantId ? { tenantId: principal.tenantId } : {}) },
@@ -93,7 +105,7 @@ export class MailService {
source: { handleKind: 'PORTAL_USER', externalId: principal.userId, ...(principal.displayName ? { displayName: principal.displayName } : {}) }, source: { handleKind: 'PORTAL_USER', externalId: principal.userId, ...(principal.displayName ? { displayName: principal.displayName } : {}) },
kind: 'EMAIL', kind: 'EMAIL',
thread: { externalThreadId: key, ...(subject ? { subject } : {}) }, thread: { externalThreadId: key, ...(subject ? { subject } : {}) },
parts, parts: allParts,
occurredAt: new Date().toISOString(), occurredAt: new Date().toISOString(),
providerEventId: key, providerEventId: key,
}, },
@@ -5,6 +5,9 @@ import { SessionVerifier } from '../platform/session.verifier';
import { PresignDownloadDto, PresignUploadDto } from './media.dto'; import { PresignDownloadDto, PresignUploadDto } from './media.dto';
import type { MessagePrincipal } from '../identity/actor.resolver'; import type { MessagePrincipal } from '../identity/actor.resolver';
/** Types that can execute script if a browser renders them top-level — served as downloads only. */
const SCRIPTABLE_MIMES = new Set(['text/html', 'application/xhtml+xml', 'image/svg+xml', 'text/xml', 'application/xml']);
@Controller('v1/media') @Controller('v1/media')
export class MediaController { export class MediaController {
constructor( constructor(
@@ -37,6 +40,12 @@ export class MediaController {
async blob(@Param('token') token: string, @Res() res: Response) { async blob(@Param('token') token: string, @Res() res: Response) {
const { data, mime } = await this.media.get(token); const { data, mime } = await this.media.get(token);
res.setHeader('Content-Type', mime); res.setHeader('Content-Type', mime);
// Never let the browser MIME-sniff an upload into something executable.
res.setHeader('X-Content-Type-Options', 'nosniff');
// Script-capable types must not render inline from our origin (stored-XSS) — force a download.
// Images/video/audio/pdf stay inline so the app can preview them. Note <img>/<video> still embed
// fine even with attachment disposition; only top-level navigation to the blob is affected.
if (SCRIPTABLE_MIMES.has(mime)) res.setHeader('Content-Disposition', 'attachment');
res.setHeader('Cache-Control', 'private, max-age=3600'); res.setHeader('Cache-Control', 'private, max-age=3600');
res.send(data); res.send(data);
} }
@@ -13,9 +13,11 @@ const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/i
const prisma = new PrismaClient({ datasources: { db: { url } } }); const prisma = new PrismaClient({ datasources: { db: { url } } });
const asService = prisma as unknown as PrismaService; const asService = prisma as unknown as PrismaService;
const ports = { ...makeFakePorts(), opa: new DevOpaPort() } as IiosPlatformPorts; const ports = { ...makeFakePorts(), opa: new DevOpaPort() } as IiosPlatformPorts;
const svc = () => new MediaService(new LocalDiskStorage(), ports, new ActorResolver(asService)); const svc = () => new MediaService(new LocalDiskStorage(), ports, new ActorResolver(asService), asService);
const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Alice' }; const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Alice' };
const DAY_MS = 24 * 60 * 60 * 1000;
// point storage at an isolated temp dir for the test run // point storage at an isolated temp dir for the test run
process.env.MEDIA_DIR = process.env.MEDIA_DIR ?? '/tmp/iios-media-test'; process.env.MEDIA_DIR = process.env.MEDIA_DIR ?? '/tmp/iios-media-test';
@@ -70,3 +72,51 @@ describe('MediaService (presigned local storage)', () => {
await expect(svc().presignDownload(alice, 'some-other-scope/abc', 'image/png')).rejects.toThrow(); await expect(svc().presignDownload(alice, 'some-other-scope/abc', 'image/png')).rejects.toThrow();
}); });
}); });
describe('MediaService orphan sweep', () => {
async function upload(s: MediaService): Promise<string> {
const bytes = Buffer.from('orphan-candidate');
const { objectKey, uploadUrl } = await s.presignUpload(alice, { mime: 'image/png', sizeBytes: bytes.length });
await s.put(tokenFrom(uploadUrl), bytes);
return objectKey;
}
it('put() records a tracking row for the stored object', async () => {
const key = await upload(svc());
const row = await prisma.iiosMediaObject.findUnique({ where: { objectKey: key } });
expect(row).not.toBeNull();
expect(row!.mime).toBe('image/png');
});
it('reaps an object that is past the grace window and unreferenced', async () => {
const s = svc();
const key = await upload(s);
// Simulate the grace window having elapsed by sweeping "in the future".
const deleted = await s.sweepOrphans(Date.now() + 2 * DAY_MS);
expect(deleted).toBe(1);
expect(await prisma.iiosMediaObject.findUnique({ where: { objectKey: key } })).toBeNull();
});
it('keeps an object still within the grace window', async () => {
const s = svc();
const key = await upload(s);
expect(await s.sweepOrphans(Date.now())).toBe(0);
expect(await prisma.iiosMediaObject.findUnique({ where: { objectKey: key } })).not.toBeNull();
});
it('keeps an object a message part references, even past grace', async () => {
const s = svc();
const key = await upload(s);
const scope = await new ActorResolver(asService).resolveScope(alice);
await prisma.iiosInteraction.create({
data: {
scopeId: scope.id,
kind: 'MESSAGE',
idempotencyKey: 'ref-1',
parts: { create: [{ partIndex: 0, kind: 'MEDIA_REF', contentRef: key }] },
},
});
expect(await s.sweepOrphans(Date.now() + 2 * DAY_MS)).toBe(0);
expect(await prisma.iiosMediaObject.findUnique({ where: { objectKey: key } })).not.toBeNull();
});
});
@@ -1,12 +1,15 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { BadRequestException, ForbiddenException, Inject, Injectable, PayloadTooLargeException } from '@nestjs/common'; import { BadRequestException, ForbiddenException, Inject, Injectable, Logger, type OnModuleDestroy, type OnModuleInit, PayloadTooLargeException } from '@nestjs/common';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import type { IiosPlatformPorts } from '@insignia/iios-contracts'; import type { IiosPlatformPorts } from '@insignia/iios-contracts';
import { PLATFORM_PORTS } from '../platform/platform-ports'; import { PLATFORM_PORTS } from '../platform/platform-ports';
import { PrismaService } from '../prisma/prisma.service';
import { decideOrThrow } from '../platform/fail-closed'; import { decideOrThrow } from '../platform/fail-closed';
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver'; import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
import { STORAGE_PORT, type StoragePort } from './storage.port'; import { STORAGE_PORT, type StoragePort } from './storage.port';
const DAY_MS = 24 * 60 * 60 * 1000;
interface UploadToken { op: 'put'; objectKey: string; mime: string; maxBytes: number } interface UploadToken { op: 'put'; objectKey: string; mime: string; maxBytes: number }
interface DownloadToken { op: 'get'; objectKey: string; mime: string } interface DownloadToken { op: 'get'; objectKey: string; mime: string }
@@ -17,16 +20,35 @@ interface DownloadToken { op: 'get'; objectKey: string; mime: string }
* kernel only ever stores the object key (contentRef) on a MessagePart. * kernel only ever stores the object key (contentRef) on a MessagePart.
*/ */
@Injectable() @Injectable()
export class MediaService { export class MediaService implements OnModuleInit, OnModuleDestroy {
private readonly secret = process.env.MEDIA_SECRET?.trim() || 'dev-media-secret'; private readonly secret = process.env.MEDIA_SECRET?.trim() || 'dev-media-secret';
private readonly publicUrl = (process.env.PUBLIC_URL?.trim() || `http://localhost:${process.env.PORT ?? 3200}`).replace(/\/$/, ''); private readonly publicUrl = (process.env.PUBLIC_URL?.trim() || `http://localhost:${process.env.PORT ?? 3200}`).replace(/\/$/, '');
/** Objects unreferenced by any message part AND older than this are reaped by the sweep. The grace
* window must exceed the longest realistic compose time so an attached-but-unsent file survives. */
private readonly orphanGraceMs = Number(process.env.IIOS_MEDIA_ORPHAN_GRACE_MS ?? DAY_MS);
private readonly logger = new Logger(MediaService.name);
private gcTimer?: ReturnType<typeof setInterval>;
constructor( constructor(
@Inject(STORAGE_PORT) private readonly storage: StoragePort, @Inject(STORAGE_PORT) private readonly storage: StoragePort,
@Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts, @Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts,
private readonly actors: ActorResolver, private readonly actors: ActorResolver,
private readonly prisma: PrismaService,
) {} ) {}
onModuleInit(): void {
const ms = Number(process.env.IIOS_MEDIA_GC_INTERVAL_MS ?? 0);
if (ms > 0) {
this.gcTimer = setInterval(() => {
void this.sweepOrphans().catch((err) => this.logger.warn(`media orphan sweep failed: ${(err as Error).message}`));
}, ms);
}
}
onModuleDestroy(): void {
if (this.gcTimer) clearInterval(this.gcTimer);
}
/** Authorize an upload and return a short-lived signed PUT url + the object key. */ /** Authorize an upload and return a short-lived signed PUT url + the object key. */
async presignUpload( async presignUpload(
principal: MessagePrincipal, principal: MessagePrincipal,
@@ -44,9 +66,58 @@ export class MediaService {
const t = this.verify<UploadToken>(token, 'put'); const t = this.verify<UploadToken>(token, 'put');
if (data.length > t.maxBytes) throw new PayloadTooLargeException('upload exceeds the presigned size'); if (data.length > t.maxBytes) throw new PayloadTooLargeException('upload exceeds the presigned size');
const { sizeBytes, checksumSha256 } = await this.storage.put(t.objectKey, data, t.mime); const { sizeBytes, checksumSha256 } = await this.storage.put(t.objectKey, data, t.mime);
// Track the object so the orphan sweep can reap it if it's never referenced by a message part.
// Best-effort: tracking must never fail an otherwise-successful upload. scopeId is the key prefix.
const scopeId = t.objectKey.split('/')[0] ?? 'unknown';
await this.prisma.iiosMediaObject
.upsert({
where: { objectKey: t.objectKey },
create: { scopeId, objectKey: t.objectKey, mime: t.mime, sizeBytes: BigInt(sizeBytes) },
update: { sizeBytes: BigInt(sizeBytes) },
})
.catch((err) => this.logger.warn(`media tracking upsert failed for ${t.objectKey}: ${(err as Error).message}`));
return { objectKey: t.objectKey, sizeBytes, checksumSha256 }; return { objectKey: t.objectKey, sizeBytes, checksumSha256 };
} }
/**
* Reap orphaned media: objects older than the grace window that NO message part references. A
* file attached in a composer uploads immediately (direct-to-storage), so an abandoned attach
* (removed, cancelled, tab closed) would otherwise linger forever there is no draft/commit step.
* "Referenced" is derived live from IiosMessagePart.contentRef, so nothing can drift. Returns the
* number of objects deleted. Safe to call repeatedly (idempotent); storage delete is best-effort.
*/
async sweepOrphans(now: number = Date.now(), batch = 1000): Promise<number> {
const cutoff = new Date(now - this.orphanGraceMs);
const candidates = await this.prisma.iiosMediaObject.findMany({
where: { createdAt: { lt: cutoff } },
select: { id: true, objectKey: true },
take: batch,
});
if (candidates.length === 0) return 0;
const keys = candidates.map((c) => c.objectKey);
const referenced = new Set(
(
await this.prisma.iiosMessagePart.findMany({
where: { contentRef: { in: keys } },
select: { contentRef: true },
})
)
.map((p) => p.contentRef)
.filter((ref): ref is string => ref !== null),
);
const orphans = candidates.filter((c) => !referenced.has(c.objectKey));
let deleted = 0;
for (const o of orphans) {
await this.storage.remove(o.objectKey).catch((err) => this.logger.warn(`storage remove failed for ${o.objectKey}: ${(err as Error).message}`));
await this.prisma.iiosMediaObject.delete({ where: { id: o.id } }).catch(() => undefined);
deleted += 1;
}
if (deleted > 0) this.logger.log(`media orphan sweep reaped ${deleted} object(s)`);
return deleted;
}
/** Authorize a download and return a short-lived signed GET url (tenant-fenced). */ /** Authorize a download and return a short-lived signed GET url (tenant-fenced). */
async presignDownload(principal: MessagePrincipal, contentRef: string, mime = 'application/octet-stream'): Promise<{ url: string }> { async presignDownload(principal: MessagePrincipal, contentRef: string, mime = 'application/octet-stream'): Promise<{ url: string }> {
const scope = await this.actors.resolveScope(principal); const scope = await this.actors.resolveScope(principal);
@@ -5,7 +5,7 @@ import { MessageGateway } from './message.gateway';
import type { MessageService, MessagePrincipal } from './message.service'; import type { MessageService, MessagePrincipal } from './message.service';
import { SessionVerifier } from '../platform/session.verifier'; import { SessionVerifier } from '../platform/session.verifier';
import type { OutboxBus } from '../outbox/outbox.bus'; import type { OutboxBus } from '../outbox/outbox.bus';
import type { PresenceService } from '../notifications/presence.service'; import type { PresencePort } from '../notifications/presence.port';
// Realtime delegation: the browser opens the socket with a short-lived token minted for the // Realtime delegation: the browser opens the socket with a short-lived token minted for the
// realtime audience (iios-message). When IIOS_REALTIME_AUDIENCE is set, the gateway accepts ONLY // realtime audience (iios-message). When IIOS_REALTIME_AUDIENCE is set, the gateway accepts ONLY
@@ -54,7 +54,7 @@ function gatewayReturning(principal: MessagePrincipal): MessageGateway {
undefined as unknown as MessageService, undefined as unknown as MessageService,
session, session,
undefined as unknown as OutboxBus, undefined as unknown as OutboxBus,
undefined as unknown as PresenceService, undefined as unknown as PresencePort,
); );
} }
@@ -1,5 +1,5 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { Logger } from '@nestjs/common'; import { Inject, Logger } from '@nestjs/common';
import { import {
ConnectedSocket, ConnectedSocket,
MessageBody, MessageBody,
@@ -16,7 +16,7 @@ import { logJson } from '../observability/logger';
import { MessageService, type MessagePrincipal } from './message.service'; import { MessageService, type MessagePrincipal } from './message.service';
import { SessionVerifier } from '../platform/session.verifier'; import { SessionVerifier } from '../platform/session.verifier';
import { OutboxBus } from '../outbox/outbox.bus'; import { OutboxBus } from '../outbox/outbox.bus';
import { PresenceService } from '../notifications/presence.service'; import { PRESENCE_PORT, type PresencePort } from '../notifications/presence.port';
interface SocketState { interface SocketState {
principal: MessagePrincipal; principal: MessagePrincipal;
@@ -39,7 +39,7 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection, OnGat
private readonly messages: MessageService, private readonly messages: MessageService,
private readonly session: SessionVerifier, private readonly session: SessionVerifier,
private readonly bus: OutboxBus, private readonly bus: OutboxBus,
private readonly presence: PresenceService, @Inject(PRESENCE_PORT) private readonly presence: PresencePort,
) {} ) {}
afterInit(): void { afterInit(): void {
@@ -77,7 +77,8 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection, OnGat
} }
handleDisconnect(client: Socket): void { handleDisconnect(client: Socket): void {
this.presence.clearSocket(client.id); // Fire-and-forget: presence is best-effort and must not block the disconnect path.
void this.presence.clearSocket(client.id).catch((err) => this.logger.warn(`presence clear failed: ${(err as Error).message}`));
} }
/** The app reports which thread is in the foreground (or null when blurred) — presence. */ /** The app reports which thread is in the foreground (or null when blurred) — presence. */
@@ -85,7 +86,9 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection, OnGat
focusThread(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string | null }): void { focusThread(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string | null }): void {
const state = client.data as SocketState | undefined; const state = client.data as SocketState | undefined;
if (!state?.principal) return; if (!state?.principal) return;
this.presence.setFocus(client.id, state.principal.userId, body?.threadId ?? null); void this.presence
.setFocus(client.id, state.principal.userId, body?.threadId ?? null)
.catch((err) => this.logger.warn(`presence set failed: ${(err as Error).message}`));
} }
@SubscribeMessage('open_thread') @SubscribeMessage('open_thread')
@@ -156,7 +159,9 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection, OnGat
async read(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string; interactionId: string }) { async read(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string; interactionId: string }) {
const { principal } = client.data as SocketState; const { principal } = client.data as SocketState;
const r = await this.messages.markRead(body.threadId, principal, body.interactionId); const r = await this.messages.markRead(body.threadId, principal, body.interactionId);
this.server.to(body.threadId).emit('receipt', { interactionId: r.interactionId, actorId: r.actorId, kind: 'READ' }); // userId as well as actorId: actorId is an IIOS actor UUID, but clients hold the caller's
// USERID, so without this they cannot tell their own receipt from someone else's.
this.server.to(body.threadId).emit('receipt', { interactionId: r.interactionId, actorId: r.actorId, userId: principal.userId, kind: 'READ' });
return { ok: true }; return { ok: true };
} }
@@ -164,7 +169,9 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection, OnGat
async delivered(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string; interactionId: string }) { async delivered(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string; interactionId: string }) {
const { principal } = client.data as SocketState; const { principal } = client.data as SocketState;
const r = await this.messages.markDelivered(body.threadId, principal, body.interactionId); const r = await this.messages.markDelivered(body.threadId, principal, body.interactionId);
this.server.to(body.threadId).emit('receipt', { interactionId: r.interactionId, actorId: r.actorId, kind: 'DELIVERED' }); // userId as well as actorId: actorId is an IIOS actor UUID, but clients hold the caller's
// USERID, so without this they cannot tell their own receipt from someone else's.
this.server.to(body.threadId).emit('receipt', { interactionId: r.interactionId, actorId: r.actorId, userId: principal.userId, kind: 'DELIVERED' });
return { ok: true }; return { ok: true };
} }
@@ -3,10 +3,21 @@ import { MessageService } from './message.service';
import { MessageGateway } from './message.gateway'; import { MessageGateway } from './message.gateway';
import { OutboxModule } from '../outbox/outbox.module'; import { OutboxModule } from '../outbox/outbox.module';
import { PresenceService } from '../notifications/presence.service'; import { PresenceService } from '../notifications/presence.service';
import { RedisPresenceService } from '../notifications/redis-presence.service';
import { PRESENCE_PORT } from '../notifications/presence.port';
/**
* PRESENCE_PORT is single-instance in-memory by default; with REDIS_URL set it's Redis-backed so
* "who is viewing what" is shared across replicas (mirrors the socket.io Redis adapter gating).
*/
const presenceProvider = {
provide: PRESENCE_PORT,
useFactory: () => (process.env.REDIS_URL ? new RedisPresenceService(process.env.REDIS_URL) : new PresenceService()),
};
@Module({ @Module({
imports: [OutboxModule], imports: [OutboxModule],
providers: [MessageService, MessageGateway, PresenceService], providers: [MessageService, MessageGateway, presenceProvider],
exports: [MessageService, PresenceService], exports: [MessageService, PRESENCE_PORT],
}) })
export class MessageModule {} export class MessageModule {}
@@ -1,5 +1,5 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { Inject, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { CloudEvent, IIOS_EVENTS, IiosPlatformPorts } from '@insignia/iios-contracts'; import { CloudEvent, IIOS_EVENTS, IiosPlatformPorts } from '@insignia/iios-contracts';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
@@ -74,6 +74,10 @@ export interface OpenThreadResult {
*/ */
@Injectable() @Injectable()
export class MessageService { export class MessageService {
/** Ceiling on one bulk participant import bounds the work per request and the blast radius of a
* mistaken import. Raise here if a larger roster copy is ever needed. */
static readonly MAX_BULK_PARTICIPANTS = 200;
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
@Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts, @Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts,
@@ -114,10 +118,18 @@ export class MessageService {
const actor = await this.actors.resolveActor(thread.scopeId, principal); const actor = await this.actors.resolveActor(thread.scopeId, principal);
const alreadyMember = const alreadyMember =
(await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: actor.id } } })) !== null; (await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: actor.id } } })) !== null;
// Join is governed ONLY for threads that opted into a membership model (chat dm/group); // Join is governed ONLY for threads that opted into a membership model (chat dm/group/channel);
// support/inbox/generic threads (no membership attr) keep open-join, unchanged. // support/inbox/generic threads (no membership attr) keep open-join, unchanged. A PUBLIC channel
const membership = (thread.metadata as { membership?: string } | null)?.membership; // is the one membership type that also allows open self-join (policy reads `visibility`).
await decideOrThrow(this.ports, { action: 'iios.thread.join', threadId, scopeId: thread.scopeId, membership, alreadyMember }); const bag = (thread.metadata as { membership?: string; visibility?: string } | null) ?? {};
await decideOrThrow(this.ports, {
action: 'iios.thread.join',
threadId,
scopeId: thread.scopeId,
membership: bag.membership,
visibility: bag.visibility,
alreadyMember,
});
await this.actors.ensureParticipant(threadId, actor.id); await this.actors.ensureParticipant(threadId, actor.id);
return { threadId, status: thread.status, history: await this.history(threadId) }; return { threadId, status: thread.status, history: await this.history(threadId) };
} }
@@ -159,6 +171,211 @@ export class MessageService {
return { threadId, participantCount: participantCount + 1 }; return { threadId, participantCount: participantCount + 1 };
} }
/**
* Bulk sibling of {@link addParticipant}: add many users in ONE governed operation. The app uses
* this to import a roster (e.g. "add everyone from that channel"); the kernel stays generic it
* receives an explicit list of userIds and never learns where the list came from.
*
* Deliberately NOT atomic: a single unresolvable user must not sink the whole import, so each is
* attempted independently and the outcome is reported per user. Already-members are `skipped`,
* which makes a re-run a no-op.
*/
async addParticipants(
threadId: string,
principal: MessagePrincipal,
targetUserIds: string[],
role = 'MEMBER',
): Promise<{ threadId: string; added: string[]; skipped: string[]; failed: string[]; participantCount: number }> {
const unique = [...new Set(targetUserIds.map((u) => u.trim()).filter(Boolean))];
if (unique.length === 0) throw new BadRequestException('at least one userId is required');
if (unique.length > MessageService.MAX_BULK_PARTICIPANTS) {
throw new BadRequestException(`at most ${MessageService.MAX_BULK_PARTICIPANTS} participants can be added at once`);
}
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
if (!thread) throw new NotFoundException('thread not found');
const caller = await this.actors.resolveActor(thread.scopeId, principal);
const callerP = await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: caller.id } } });
const participantCount = await this.prisma.iiosThreadParticipant.count({ where: { threadId } });
const membership = (thread.metadata as { membership?: string } | null)?.membership;
// ONE decision for the whole batch — `targetCount` lets policy reason about the resulting size
// (e.g. the dm two-person cap) instead of being asked the same question N times.
await decideOrThrow(this.ports, {
action: 'iios.thread.participant.add',
threadId,
scopeId: thread.scopeId,
membership,
participantCount,
callerRole: callerP?.participantRole,
targetCount: unique.length,
role,
});
const scope = await this.actors.resolveScope(principal);
const added: string[] = [];
const skipped: string[] = [];
const failed: string[] = [];
for (const targetUserId of unique) {
try {
const target = await this.actors.resolveActor(scope.id, {
userId: targetUserId,
appId: principal.appId,
orgId: principal.orgId,
tenantId: principal.tenantId,
displayName: targetUserId,
});
const existing = await this.prisma.iiosThreadParticipant.findUnique({
where: { threadId_actorId: { threadId, actorId: target.id } },
});
if (existing) {
skipped.push(targetUserId);
continue;
}
await this.actors.ensureParticipant(threadId, target.id, role);
added.push(targetUserId);
} catch {
failed.push(targetUserId);
}
}
return { threadId, added, skipped, failed, participantCount: participantCount + added.length };
}
/**
* Governed thread rename (a generic subject update). Policy decides who may rename for a
* membership thread the dev OPA requires the caller be a group ADMIN. The kernel only writes
* the subject; "group settings" meaning lives in the app + policy, not here.
*/
async renameThread(threadId: string, principal: MessagePrincipal, subject: string): Promise<{ threadId: string; subject: string }> {
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
if (!thread) throw new NotFoundException('thread not found');
const caller = await this.actors.resolveActor(thread.scopeId, principal);
const callerP = await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: caller.id } } });
const membership = (thread.metadata as { membership?: string } | null)?.membership;
await decideOrThrow(this.ports, {
action: 'iios.thread.update',
threadId,
scopeId: thread.scopeId,
membership,
callerRole: callerP?.participantRole,
});
await this.prisma.iiosThread.update({ where: { id: threadId }, data: { subject } });
return { threadId, subject };
}
/**
* Governed participant removal. Policy decides who may remove for a membership thread the dev
* OPA requires the caller be a group ADMIN. Removing a non-participant is a no-op success.
*/
async removeParticipant(threadId: string, principal: MessagePrincipal, targetUserId: string): Promise<{ threadId: string; participantCount: number }> {
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
if (!thread) throw new NotFoundException('thread not found');
const caller = await this.actors.resolveActor(thread.scopeId, principal);
const callerP = await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: caller.id } } });
const membership = (thread.metadata as { membership?: string } | null)?.membership;
await decideOrThrow(this.ports, {
action: 'iios.thread.participant.remove',
threadId,
scopeId: thread.scopeId,
membership,
callerRole: callerP?.participantRole,
targetUserId,
});
const scope = await this.actors.resolveScope(principal);
const target = await this.actors.resolveActor(scope.id, {
userId: targetUserId, appId: principal.appId, orgId: principal.orgId, tenantId: principal.tenantId, displayName: targetUserId,
});
await this.prisma.iiosThreadParticipant.deleteMany({ where: { threadId, actorId: target.id } });
const participantCount = await this.prisma.iiosThreadParticipant.count({ where: { threadId } });
return { threadId, participantCount };
}
/** Members of a thread with their role — drives the group settings member list. Read is policy-scoped. */
async listParticipants(threadId: string, principal: MessagePrincipal): Promise<Array<{ userId: string; displayName: string; role: string }>> {
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
if (!thread) throw new NotFoundException('thread not found');
// Roster reads are membership-governed (a private channel's members must not be enumerable by
// a non-member), so the decision carries the caller's role + the thread's opaque attributes.
const caller = await this.actors.resolveActor(thread.scopeId, principal);
const callerP = await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: caller.id } } });
const meta = thread.metadata as { membership?: string; visibility?: string } | null;
await decideOrThrow(this.ports, {
action: 'iios.thread.participant.list',
threadId,
scopeId: thread.scopeId,
membership: meta?.membership,
visibility: meta?.visibility,
callerRole: callerP?.participantRole,
});
const parts = await this.prisma.iiosThreadParticipant.findMany({
where: { threadId },
include: { actor: { include: { sourceHandle: true } } },
});
return parts.map((p) => ({
userId: p.actor?.sourceHandle?.externalId ?? '',
displayName: p.actor?.displayName ?? p.actor?.sourceHandle?.externalId ?? 'unknown',
role: p.participantRole ?? 'MEMBER',
}));
}
/**
* Scope-wide thread discovery the ONE generic primitive channels need beyond dm/group.
* Unlike listThreads (membership-scoped), this returns threads in the caller's scope matching an
* opaque metadata filter (e.g. {membership:'channel', visibility:'public'}) REGARDLESS of whether
* the caller is a participant, each flagged `joined`. Governed by policy (fail-closed) + fenced to
* the caller's own scope. The kernel never interprets the filter keys.
*/
async discoverThreads(
principal: MessagePrincipal,
filter?: { metadata?: Record<string, string> },
): Promise<Array<{ threadId: string; subject: string | null; metadata: Record<string, unknown> | null; participantCount: number; joined: boolean }>> {
const scope = await this.actors.findScope(principal);
if (!scope) return [];
await decideOrThrow(this.ports, { action: 'iios.thread.discover', scopeId: scope.id });
const actor = await this.actors.resolveActor(scope.id, principal);
const inScope = await this.prisma.iiosThread.findMany({ where: { scopeId: scope.id } });
const metaFilter = filter?.metadata;
const matched = metaFilter
? inScope.filter((t) => {
const bag = (t.metadata as Record<string, unknown> | null) ?? {};
return Object.entries(metaFilter).every(([k, v]) => bag[k] === v);
})
: inScope;
if (matched.length === 0) return [];
const ids = matched.map((t) => t.id);
const parts = await this.prisma.iiosThreadParticipant.groupBy({ by: ['threadId'], where: { threadId: { in: ids } }, _count: { actorId: true } });
const countBy = new Map(parts.map((p) => [p.threadId, p._count.actorId]));
const mine = await this.prisma.iiosThreadParticipant.findMany({ where: { threadId: { in: ids }, actorId: actor.id }, select: { threadId: true } });
const joinedSet = new Set(mine.map((m) => m.threadId));
return matched.map((t) => ({
threadId: t.id,
subject: t.subject,
metadata: (t.metadata as Record<string, unknown> | null) ?? null,
participantCount: countBy.get(t.id) ?? 0,
joined: joinedSet.has(t.id),
}));
}
/** Self-leave: remove the caller's own participant row. Governed (a member may always leave). */
async leaveThread(threadId: string, principal: MessagePrincipal): Promise<{ threadId: string; participantCount: number }> {
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
if (!thread) throw new NotFoundException('thread not found');
const actor = await this.actors.resolveActor(thread.scopeId, principal);
const callerP = await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: actor.id } } });
const membership = (thread.metadata as { membership?: string } | null)?.membership;
await decideOrThrow(this.ports, { action: 'iios.thread.leave', threadId, scopeId: thread.scopeId, membership, callerRole: callerP?.participantRole });
await this.prisma.iiosThreadParticipant.deleteMany({ where: { threadId, actorId: actor.id } });
const participantCount = await this.prisma.iiosThreadParticipant.count({ where: { threadId } });
return { threadId, participantCount };
}
/** Toggle the caller's per-thread notification mute flag. */ /** Toggle the caller's per-thread notification mute flag. */
async muteThread(threadId: string, principal: MessagePrincipal, muted: boolean): Promise<{ threadId: string; muted: boolean }> { async muteThread(threadId: string, principal: MessagePrincipal, muted: boolean): Promise<{ threadId: string; muted: boolean }> {
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } }); const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
@@ -124,6 +124,83 @@ describe('Governed membership + replies (v1.1, policy-enforced)', () => {
await expect(s.addParticipant(threadId, bob, 'carol')).rejects.toBeInstanceOf(PolicyDeniedError); // bob is MEMBER await expect(s.addParticipant(threadId, bob, 'carol')).rejects.toBeInstanceOf(PolicyDeniedError); // bob is MEMBER
}); });
it('group settings: admin renames + lists members + removes; a plain member cannot rename/remove', async () => {
const s = gov();
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN', subject: 'Design' });
await s.addParticipant(threadId, alice, 'bob');
// admin renames
expect((await s.renameThread(threadId, alice, 'Design Team')).subject).toBe('Design Team');
// a plain member cannot rename
await expect(s.renameThread(threadId, bob, 'Hacked')).rejects.toBeInstanceOf(PolicyDeniedError);
// member list carries roles
const members = await s.listParticipants(threadId, alice);
expect(members.map((m) => m.userId).sort()).toEqual(['alice', 'bob']);
expect(members.find((m) => m.userId === 'alice')?.role).toBe('ADMIN');
// a plain member cannot remove
await expect(s.removeParticipant(threadId, bob, 'alice')).rejects.toBeInstanceOf(PolicyDeniedError);
// admin removes bob
expect((await s.removeParticipant(threadId, alice, 'bob')).participantCount).toBe(1);
expect(await prisma.iiosThreadParticipant.count({ where: { threadId } })).toBe(1);
});
it('roster reads are membership-governed: a non-member cannot list a private channels members', async () => {
const s = gov();
const { threadId: priv } = await s.openThread(null, alice, { membership: 'channel', metadata: { visibility: 'private' }, subject: 'deals', creatorRole: 'ADMIN' });
// bob is not a member — he must not be able to enumerate who is.
await expect(s.listParticipants(priv, bob)).rejects.toBeInstanceOf(PolicyDeniedError);
await s.addParticipant(priv, alice, 'bob');
expect((await s.listParticipants(priv, bob)).map((m) => m.userId).sort()).toEqual(['alice', 'bob']);
// a PUBLIC channel's roster stays open (it is discoverable anyway)
const { threadId: pub } = await s.openThread(null, alice, { membership: 'channel', metadata: { visibility: 'public' }, subject: 'general', creatorRole: 'ADMIN' });
expect((await s.listParticipants(pub, bob)).map((m) => m.userId)).toEqual(['alice']);
});
it('addParticipants: bulk-adds in one governed call, skipping existing members (re-run is a no-op)', async () => {
const s = gov();
const { threadId } = await s.openThread(null, alice, { membership: 'channel', metadata: { visibility: 'private' }, subject: 'ops', creatorRole: 'ADMIN' });
await s.addParticipant(threadId, alice, 'bob'); // bob is already in
const res = await s.addParticipants(threadId, alice, ['bob', 'carol', 'dave']);
expect(res.added.sort()).toEqual(['carol', 'dave']);
expect(res.skipped).toEqual(['bob']);
expect(res.failed).toEqual([]);
expect(res.participantCount).toBe(4); // alice, bob, carol, dave
expect(await prisma.iiosThreadParticipant.count({ where: { threadId } })).toBe(4);
// idempotent: a second identical import adds nobody
const again = await s.addParticipants(threadId, alice, ['bob', 'carol', 'dave']);
expect(again.added).toEqual([]);
expect(again.skipped.sort()).toEqual(['bob', 'carol', 'dave']);
expect(await prisma.iiosThreadParticipant.count({ where: { threadId } })).toBe(4);
});
it('addParticipants is governed by the same policy as a single add (a plain member is denied)', async () => {
const s = gov();
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
await s.addParticipant(threadId, alice, 'bob'); // bob joins as a plain MEMBER
await expect(s.addParticipants(threadId, bob, ['carol', 'dave'])).rejects.toBeInstanceOf(PolicyDeniedError);
});
it('addParticipants rejects an empty list and anything over the batch cap', async () => {
const s = gov();
const { threadId } = await s.openThread(null, alice, { membership: 'channel', metadata: { visibility: 'private' }, creatorRole: 'ADMIN' });
await expect(s.addParticipants(threadId, alice, [])).rejects.toThrow(/at least one/i);
const tooMany = Array.from({ length: MessageService.MAX_BULK_PARTICIPANTS + 1 }, (_, n) => `user_${n}`);
await expect(s.addParticipants(threadId, alice, tooMany)).rejects.toThrow(/at most/i);
});
it('addParticipants respects the dm two-person cap for the whole batch', async () => {
const s = gov();
const { threadId } = await s.openThread(null, alice, { membership: 'dm' });
// alice is alone; adding two at once would make three — policy must deny the batch.
await expect(s.addParticipants(threadId, alice, ['bob', 'carol'])).rejects.toBeInstanceOf(PolicyDeniedError);
expect((await s.addParticipants(threadId, alice, ['bob'])).added).toEqual(['bob']);
});
it('self-join is governed: a non-member cannot open a thread by id; after being added, they can', async () => { it('self-join is governed: a non-member cannot open a thread by id; after being added, they can', async () => {
const s = gov(); const s = gov();
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' }); const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
@@ -132,6 +209,34 @@ describe('Governed membership + replies (v1.1, policy-enforced)', () => {
expect((await s.openThread(threadId, bob)).threadId).toBe(threadId); expect((await s.openThread(threadId, bob)).threadId).toBe(threadId);
}); });
it('channels: a PUBLIC channel allows open self-join; a PRIVATE one does not', async () => {
const s = gov();
const { threadId: pub } = await s.openThread(null, alice, { membership: 'channel', metadata: { visibility: 'public' }, subject: 'general', creatorRole: 'ADMIN' });
expect((await s.openThread(pub, bob)).threadId).toBe(pub); // bob self-joins a public channel
const { threadId: priv } = await s.openThread(null, alice, { membership: 'channel', metadata: { visibility: 'private' }, subject: 'deals', creatorRole: 'ADMIN' });
await expect(s.openThread(priv, bob)).rejects.toBeInstanceOf(PolicyDeniedError); // private = invite-only
});
it('discoverThreads browses same-scope channels with a joined flag; leave removes me', async () => {
const s = gov();
const { threadId: gen } = await s.openThread(null, alice, { membership: 'channel', metadata: { visibility: 'public' }, subject: 'general', creatorRole: 'ADMIN' });
await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' }); // a group must NOT appear in a channel browse
// bob (non-member, same scope) discovers the public channel as not-joined.
const seen = await s.discoverThreads(bob, { metadata: { membership: 'channel', visibility: 'public' } });
expect(seen.map((t) => t.threadId)).toEqual([gen]);
expect(seen[0]!.joined).toBe(false);
// alice (member) sees it joined.
expect((await s.discoverThreads(alice, { metadata: { membership: 'channel', visibility: 'public' } }))[0]!.joined).toBe(true);
// bob joins, appears in his list, then leaves.
await s.openThread(gen, bob);
expect((await s.listThreads(bob)).map((t) => t.threadId)).toContain(gen);
await s.leaveThread(gen, bob);
expect((await s.listThreads(bob)).map((t) => t.threadId)).not.toContain(gen);
});
it('listThreads returns the callers threads with membership, count, last message + unread', async () => { it('listThreads returns the callers threads with membership, count, last message + unread', async () => {
const s = gov(); const s = gov();
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' }); const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
@@ -33,6 +33,18 @@ async function sentEvents(): Promise<CloudEvent[]> {
const rows = await prisma.iiosOutboxEvent.findMany({ where: { eventType: IIOS_EVENTS.messageSent }, orderBy: { createdAt: 'asc' } }); const rows = await prisma.iiosOutboxEvent.findMany({ where: { eventType: IIOS_EVENTS.messageSent }, orderBy: { createdAt: 'asc' } });
return rows.map((r) => r.cloudEvent as unknown as CloudEvent); return rows.map((r) => r.cloudEvent as unknown as CloudEvent);
} }
/** A synthetic interaction.normalized event (the shape ingest/mail emits) for an existing interaction. */
function normalizedEvent(interactionId: string, threadId: string, kind: string): CloudEvent {
return {
specversion: '1.0',
id: `evt_norm_${interactionId}`,
type: IIOS_EVENTS.interactionNormalized,
source: 'iios/ingest/test',
time: '2026-01-01T00:00:00.000Z',
insignia: { idempotencyKey: `norm:${interactionId}` },
data: { interactionId, threadId, kind },
} as CloudEvent;
}
function makeProjector(presence = new PresenceService(), deliverResult: 'sent' | 'gone' = 'sent') { function makeProjector(presence = new PresenceService(), deliverResult: 'sent' | 'gone' = 'sent') {
const deliver = vi.fn().mockResolvedValue(deliverResult); const deliver = vi.fn().mockResolvedValue(deliverResult);
const proj = new NotificationProjector(asService, new OutboxBus(), new DlqService(asService), new ProjectionCursorService(asService), presence, { deliver } as never); const proj = new NotificationProjector(asService, new OutboxBus(), new DlqService(asService), new ProjectionCursorService(asService), presence, { deliver } as never);
@@ -51,7 +63,7 @@ describe('NotificationProjector', () => {
await seedSub('bob'); await seedSub('bob');
await m.send(threadId, alice, { content: 'hi bob' }, 'k1'); await m.send(threadId, alice, { content: 'hi bob' }, 'k1');
const { proj, deliver } = makeProjector(); const { proj, deliver } = makeProjector();
await proj.onMessageSent((await sentEvents())[0]!); await proj.onEvent((await sentEvents())[0]!);
expect(deliver).toHaveBeenCalledOnce(); expect(deliver).toHaveBeenCalledOnce();
expect(deliver.mock.calls[0][0].endpoint).toContain('push/bob'); expect(deliver.mock.calls[0][0].endpoint).toContain('push/bob');
}); });
@@ -63,7 +75,7 @@ describe('NotificationProjector', () => {
await seedSub('bob'); await seedSub('bob');
await m.send(threadId, alice, { content: 'hello all' }, 'k1'); await m.send(threadId, alice, { content: 'hello all' }, 'k1');
const { proj, deliver } = makeProjector(); const { proj, deliver } = makeProjector();
await proj.onMessageSent((await sentEvents())[0]!); await proj.onEvent((await sentEvents())[0]!);
expect(deliver).not.toHaveBeenCalled(); expect(deliver).not.toHaveBeenCalled();
}); });
@@ -74,7 +86,7 @@ describe('NotificationProjector', () => {
await seedSub('bob'); await seedSub('bob');
await m.send(threadId, alice, { content: 'hey @bob' }, 'k1', undefined, undefined, ['bob']); await m.send(threadId, alice, { content: 'hey @bob' }, 'k1', undefined, undefined, ['bob']);
const { proj, deliver } = makeProjector(); const { proj, deliver } = makeProjector();
await proj.onMessageSent((await sentEvents())[0]!); await proj.onEvent((await sentEvents())[0]!);
expect(deliver).toHaveBeenCalledOnce(); expect(deliver).toHaveBeenCalledOnce();
}); });
@@ -88,7 +100,7 @@ describe('NotificationProjector', () => {
await m.send(threadId, alice, { content: 'answer' }, 'k2', undefined, parent.id); // alice replies to bob (no mention) await m.send(threadId, alice, { content: 'answer' }, 'k2', undefined, parent.id); // alice replies to bob (no mention)
const { proj, deliver } = makeProjector(); const { proj, deliver } = makeProjector();
const evs = await sentEvents(); const evs = await sentEvents();
await proj.onMessageSent(evs[evs.length - 1]!); // project the reply await proj.onEvent(evs[evs.length - 1]!); // project the reply
expect(deliver).toHaveBeenCalledOnce(); expect(deliver).toHaveBeenCalledOnce();
expect(deliver.mock.calls[0][0].endpoint).toContain('push/bob'); expect(deliver.mock.calls[0][0].endpoint).toContain('push/bob');
}); });
@@ -102,7 +114,7 @@ describe('NotificationProjector', () => {
const presence = new PresenceService(); const presence = new PresenceService();
presence.setFocus('sockB', 'bob', threadId); presence.setFocus('sockB', 'bob', threadId);
const { proj, deliver } = makeProjector(presence); const { proj, deliver } = makeProjector(presence);
await proj.onMessageSent((await sentEvents())[0]!); await proj.onEvent((await sentEvents())[0]!);
expect(deliver).not.toHaveBeenCalled(); expect(deliver).not.toHaveBeenCalled();
}); });
@@ -114,7 +126,31 @@ describe('NotificationProjector', () => {
await prisma.iiosThreadParticipant.update({ where: { threadId_actorId: { threadId, actorId: bobActorId } }, data: { muted: true } }); await prisma.iiosThreadParticipant.update({ where: { threadId_actorId: { threadId, actorId: bobActorId } }, data: { muted: true } });
await m.send(threadId, alice, { content: 'hi' }, 'k1'); await m.send(threadId, alice, { content: 'hi' }, 'k1');
const { proj, deliver } = makeProjector(); const { proj, deliver } = makeProjector();
await proj.onMessageSent((await sentEvents())[0]!); await proj.onEvent((await sentEvents())[0]!);
expect(deliver).not.toHaveBeenCalled();
});
it('mail (interaction.normalized, kind EMAIL): notifies the recipient even in a group thread', async () => {
// A group thread would NOT notify bob on message.sent (proven above); the EMAIL branch always does.
const m = ms();
const { threadId } = await m.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
await m.addParticipant(threadId, alice, 'bob');
await seedSub('bob');
const sent = await m.send(threadId, alice, { content: 'your invoice is attached' }, 'k1');
const { proj, deliver } = makeProjector();
await proj.onEvent(normalizedEvent(sent.id, threadId, 'EMAIL'));
expect(deliver).toHaveBeenCalledOnce();
expect(deliver.mock.calls[0][0].endpoint).toContain('push/bob');
});
it('interaction.normalized that is NOT mail (kind MESSAGE): does not notify', async () => {
const m = ms();
const { threadId } = await m.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
await m.addParticipant(threadId, alice, 'bob');
await seedSub('bob');
const sent = await m.send(threadId, alice, { content: 'hello all' }, 'k1');
const { proj, deliver } = makeProjector();
await proj.onEvent(normalizedEvent(sent.id, threadId, 'MESSAGE'));
expect(deliver).not.toHaveBeenCalled(); expect(deliver).not.toHaveBeenCalled();
}); });
@@ -125,7 +161,7 @@ describe('NotificationProjector', () => {
await seedSub('bob'); await seedSub('bob');
await m.send(threadId, alice, { content: 'hi' }, 'k1'); await m.send(threadId, alice, { content: 'hi' }, 'k1');
const { proj } = makeProjector(new PresenceService(), 'gone'); const { proj } = makeProjector(new PresenceService(), 'gone');
await proj.onMessageSent((await sentEvents())[0]!); await proj.onEvent((await sentEvents())[0]!);
expect(await prisma.iiosNotificationSubscription.count()).toBe(0); expect(await prisma.iiosNotificationSubscription.count()).toBe(0);
}); });
}); });
@@ -4,7 +4,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { OutboxBus } from '../outbox/outbox.bus'; import { OutboxBus } from '../outbox/outbox.bus';
import { DlqService } from '../outbox/dlq.service'; import { DlqService } from '../outbox/dlq.service';
import { ProjectionCursorService } from '../projection/projection-cursor.service'; import { ProjectionCursorService } from '../projection/projection-cursor.service';
import { PresenceService } from './presence.service'; import { PRESENCE_PORT, type PresencePort } from './presence.port';
import { NOTIFICATION_PORT, type NotificationPort } from './notification.port'; import { NOTIFICATION_PORT, type NotificationPort } from './notification.port';
interface MsgData { interface MsgData {
@@ -14,14 +14,22 @@ interface MsgData {
mentions?: string[]; mentions?: string[];
} }
interface NormalizedData {
interactionId: string;
threadId: string;
kind?: string;
}
/** /**
* Turns `message.sent` into push notifications for absent recipients. Three gates: * Turns messenger sends AND inbound mail into push notifications for absent recipients:
* 1. policy DM always; group only if @mentioned or a reply to that recipient * - `message.sent` messenger policy (DM always; group only if @mentioned or reply-to-you)
* 2. presence skip if the recipient is currently focused on that thread * - `interaction.normalized` (kind EMAIL) mail always notifies the recipient (a directed 1:1)
* 3. mute skip if the recipient muted the thread * Every candidate then passes two more gates before delivery:
* presence skip if the recipient is currently focused on that thread
* mute skip if the recipient muted the thread
* Then dispatches to each of the recipient's subscriptions; a 'gone' result prunes it. * Then dispatches to each of the recipient's subscriptions; a 'gone' result prunes it.
* Idempotent per event id (same pattern as InboxProjector). Generic: DM-vs-group is read * Idempotent per event id (same pattern as InboxProjector). Generic: DM-vs-group / mail is read
* from the opaque `membership` thread attribute here in the notification *policy*, not the kernel. * from opaque thread attributes here in the notification *policy*, not the kernel.
*/ */
@Injectable() @Injectable()
export class NotificationProjector implements OnModuleInit { export class NotificationProjector implements OnModuleInit {
@@ -32,36 +40,63 @@ export class NotificationProjector implements OnModuleInit {
private readonly bus: OutboxBus, private readonly bus: OutboxBus,
private readonly dlq: DlqService, private readonly dlq: DlqService,
private readonly cursor: ProjectionCursorService, private readonly cursor: ProjectionCursorService,
private readonly presence: PresenceService, @Inject(PRESENCE_PORT) private readonly presence: PresencePort,
@Inject(NOTIFICATION_PORT) private readonly port: NotificationPort, @Inject(NOTIFICATION_PORT) private readonly port: NotificationPort,
) {} ) {}
onModuleInit(): void { onModuleInit(): void {
this.dlq.registerHandler(this.consumer, (e) => this.onMessageSent(e)); this.dlq.registerHandler(this.consumer, (e) => this.onEvent(e));
this.bus.on(IIOS_EVENTS.messageSent, (p) => this.bus.on(IIOS_EVENTS.messageSent, (p) =>
void this.onMessageSent(p as CloudEvent).catch((err) => this.dlq.onConsumerFailure(this.consumer, p as CloudEvent, err)), void this.onEvent(p as CloudEvent).catch((err) => this.dlq.onConsumerFailure(this.consumer, p as CloudEvent, err)),
);
// Mail (external email + app-to-app) lands via ingest, which emits interaction.normalized — not
// message.sent — so subscribe here too and filter to EMAIL inside apply.
this.bus.on(IIOS_EVENTS.interactionNormalized, (p) =>
void this.onEvent(p as CloudEvent).catch((err) => this.dlq.onConsumerFailure(this.consumer, p as CloudEvent, err)),
); );
} }
async onMessageSent(event: CloudEvent): Promise<void> { async onEvent(event: CloudEvent): Promise<void> {
if (!(await this.claim(event.id))) return; // duplicate → no apply, no cursor advance if (!(await this.claim(event.id))) return; // duplicate → no apply, no cursor advance
await this.apply(event); await this.apply(event);
await this.cursor.advance(this.consumer, event); await this.cursor.advance(this.consumer, event);
} }
private async apply(event: CloudEvent): Promise<void> { private async apply(event: CloudEvent): Promise<void> {
if (event.type === IIOS_EVENTS.messageSent) return this.applyMessage(event);
if (event.type === IIOS_EVENTS.interactionNormalized) return this.applyMail(event);
}
/** Messenger send: DM always notifies; group only on @mention or reply-to-you. */
private async applyMessage(event: CloudEvent): Promise<void> {
const data = event.data as MsgData; const data = event.data as MsgData;
const thread = await this.prisma.iiosThread.findUnique({ where: { id: data.threadId } }); const thread = await this.prisma.iiosThread.findUnique({ where: { id: data.threadId } });
if (!thread) return; if (!thread) return;
const membership = (thread.metadata as { membership?: string } | null)?.membership; const membership = (thread.metadata as { membership?: string } | null)?.membership;
await this.notify(data.threadId, data.interactionId, data.senderActorId, data.mentions ?? [], membership === 'dm');
}
/** Inbound mail (kind EMAIL): a directed message — always notify the non-sender recipient(s). */
private async applyMail(event: CloudEvent): Promise<void> {
const data = event.data as NormalizedData;
if (data.kind !== 'EMAIL') return; // other normalized interactions aren't mail — ignore
const sender = await this.prisma.iiosInteraction.findUnique({ where: { id: data.interactionId }, select: { actorId: true } });
if (!sender?.actorId) return;
await this.notify(data.threadId, data.interactionId, sender.actorId, [], true);
}
/**
* Shared fan-out: load the message, then for every participant but the sender apply the policy
* (alwaysNotify OR @mentioned OR replied-to-you), presence, and mute gates before delivering.
*/
private async notify(threadId: string, interactionId: string, senderActorId: string, mentions: string[], alwaysNotify: boolean): Promise<void> {
const data = { threadId, interactionId, senderActorId };
const interaction = await this.prisma.iiosInteraction.findUnique({ const interaction = await this.prisma.iiosInteraction.findUnique({
where: { id: data.interactionId }, where: { id: data.interactionId },
include: { parts: { where: { kind: 'TEXT' }, take: 1 }, actor: { include: { sourceHandle: true } } }, include: { parts: { where: { kind: 'TEXT' }, take: 1 }, actor: { include: { sourceHandle: true } } },
}); });
const senderName = interaction?.actor?.sourceHandle?.externalId ?? 'Someone'; const senderName = interaction?.actor?.sourceHandle?.externalId ?? 'Someone';
const body = interaction?.parts[0]?.bodyText ?? 'sent an attachment'; const body = interaction?.parts[0]?.bodyText ?? 'sent an attachment';
const mentions = data.mentions ?? [];
// Parent author (for reply-to-you) — one lookup. // Parent author (for reply-to-you) — one lookup.
let parentAuthorActorId: string | null = null; let parentAuthorActorId: string | null = null;
@@ -85,10 +120,10 @@ export class NotificationProjector implements OnModuleInit {
// gate 1 — policy // gate 1 — policy
const mentioned = userId != null && mentions.includes(userId); const mentioned = userId != null && mentions.includes(userId);
const repliedToMe = parentAuthorActorId != null && parentAuthorActorId === p.actorId; const repliedToMe = parentAuthorActorId != null && parentAuthorActorId === p.actorId;
if (!(membership === 'dm' || mentioned || repliedToMe)) continue; if (!(alwaysNotify || mentioned || repliedToMe)) continue;
// gate 2 — presence // gate 2 — presence
if (userId && this.presence.isViewing(userId, data.threadId)) continue; if (userId && (await this.presence.isViewing(userId, data.threadId))) continue;
// gate 3 — mute // gate 3 — mute
if (p.muted) continue; if (p.muted) continue;
@@ -0,0 +1,15 @@
/**
* Presence seam: tracks which thread each socket has in the foreground so the notification
* projector can suppress push for a thread the recipient is actively viewing. Async so it can be
* backed by Redis across replicas (prod) or an in-memory Map on a single instance (dev).
*/
export interface PresencePort {
/** Record a socket's foregrounded thread (null when the window is blurred / no thread open). */
setFocus(socketId: string, userId: string, threadId: string | null): Promise<void>;
/** Forget everything about a socket (on disconnect). */
clearSocket(socketId: string): Promise<void>;
/** True if ANY of the user's sockets currently has this thread in the foreground. */
isViewing(userId: string, threadId: string): Promise<boolean>;
}
export const PRESENCE_PORT = Symbol('PRESENCE_PORT');
@@ -2,23 +2,23 @@ import { describe, it, expect } from 'vitest';
import { PresenceService } from './presence.service'; import { PresenceService } from './presence.service';
describe('PresenceService', () => { describe('PresenceService', () => {
it('reports viewing only for the focused thread, and clears on disconnect', () => { it('reports viewing only for the focused thread, and clears on disconnect', async () => {
const p = new PresenceService(); const p = new PresenceService();
expect(p.isViewing('alice', 'T1')).toBe(false); expect(await p.isViewing('alice', 'T1')).toBe(false);
p.setFocus('sock1', 'alice', 'T1'); await p.setFocus('sock1', 'alice', 'T1');
expect(p.isViewing('alice', 'T1')).toBe(true); expect(await p.isViewing('alice', 'T1')).toBe(true);
expect(p.isViewing('alice', 'T2')).toBe(false); // joined-elsewhere ≠ viewing expect(await p.isViewing('alice', 'T2')).toBe(false); // joined-elsewhere ≠ viewing
p.setFocus('sock1', 'alice', 'T2'); // moved focus await p.setFocus('sock1', 'alice', 'T2'); // moved focus
expect(p.isViewing('alice', 'T1')).toBe(false); expect(await p.isViewing('alice', 'T1')).toBe(false);
expect(p.isViewing('alice', 'T2')).toBe(true); expect(await p.isViewing('alice', 'T2')).toBe(true);
p.clearSocket('sock1'); await p.clearSocket('sock1');
expect(p.isViewing('alice', 'T2')).toBe(false); expect(await p.isViewing('alice', 'T2')).toBe(false);
}); });
it('any of the actors sockets counts as viewing', () => { it('any of the actors sockets counts as viewing', async () => {
const p = new PresenceService(); const p = new PresenceService();
p.setFocus('sockA', 'bob', 'T9'); await p.setFocus('sockA', 'bob', 'T9');
p.setFocus('sockB', 'bob', null); // a second tab, no focus await p.setFocus('sockB', 'bob', null); // a second tab, no focus
expect(p.isViewing('bob', 'T9')).toBe(true); expect(await p.isViewing('bob', 'T9')).toBe(true);
}); });
}); });
@@ -1,24 +1,28 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import type { PresencePort } from './presence.port';
/** /**
* In-memory focus tracker (single instance). Keyed on userId (the stable externalId the * In-memory focus tracker (single instance). Keyed on userId (the stable externalId the
* gateway has as principal.userId). NOTE: room membership viewing the sidebar joins * gateway has as principal.userId). NOTE: room membership viewing the sidebar joins
* every thread room for live updates, so presence uses an explicit `focus_thread` signal. * every thread room for live updates, so presence uses an explicit `focus_thread` signal.
* Prod (multi-replica): back this with Redis. * Multi-replica prod uses {@link RedisPresenceService} instead (wired when REDIS_URL is set).
*
* Methods are async to satisfy the PresencePort seam; the map is mutated synchronously, so an
* un-awaited setFocus is still visible to an immediately-following isViewing.
*/ */
@Injectable() @Injectable()
export class PresenceService { export class PresenceService implements PresencePort {
private readonly focus = new Map<string, { userId: string; threadId: string | null }>(); // socketId → focus private readonly focus = new Map<string, { userId: string; threadId: string | null }>(); // socketId → focus
setFocus(socketId: string, userId: string, threadId: string | null): void { async setFocus(socketId: string, userId: string, threadId: string | null): Promise<void> {
this.focus.set(socketId, { userId, threadId }); this.focus.set(socketId, { userId, threadId });
} }
clearSocket(socketId: string): void { async clearSocket(socketId: string): Promise<void> {
this.focus.delete(socketId); this.focus.delete(socketId);
} }
isViewing(userId: string, threadId: string): boolean { async isViewing(userId: string, threadId: string): Promise<boolean> {
for (const f of this.focus.values()) if (f.userId === userId && f.threadId === threadId) return true; for (const f of this.focus.values()) if (f.userId === userId && f.threadId === threadId) return true;
return false; return false;
} }
@@ -0,0 +1,64 @@
import { Logger, type OnModuleDestroy } from '@nestjs/common';
import { Redis } from 'ioredis';
import type { PresencePort } from './presence.port';
/**
* Redis-backed presence for multi-replica prod: a socket connected to replica A and a message
* projected on replica B must share the same "who is viewing what" view, which an in-memory Map
* cannot provide. Wired (in place of {@link PresenceService}) only when REDIS_URL is set.
*
* Layout (per user, tiny):
* sock:{socketId} -> userId (so clearSocket can find the user), EX TTL
* user:{userId} -> HASH socketId=threadId, EX TTL
* isViewing = does the user hash hold this threadId for any socket. Keys carry a TTL so a crashed
* replica's entries self-heal; expiry errs toward "not viewing" (push is sent), the safe default.
*/
export class RedisPresenceService implements PresencePort, OnModuleDestroy {
private readonly logger = new Logger(RedisPresenceService.name);
private readonly redis: Redis;
private static readonly TTL_SECONDS = 300;
private static readonly PREFIX = 'iios:presence';
constructor(url: string, client?: Redis) {
this.redis = client ?? new Redis(url, { maxRetriesPerRequest: null });
this.redis.on('error', (err) => this.logger.error(`redis presence error: ${err.message}`));
}
private sockKey(socketId: string): string {
return `${RedisPresenceService.PREFIX}:sock:${socketId}`;
}
private userKey(userId: string): string {
return `${RedisPresenceService.PREFIX}:user:${userId}`;
}
async setFocus(socketId: string, userId: string, threadId: string | null): Promise<void> {
const ttl = RedisPresenceService.TTL_SECONDS;
const sock = this.sockKey(socketId);
const user = this.userKey(userId);
const pipe = this.redis.multi().set(sock, userId, 'EX', ttl);
if (threadId === null) {
// Blurred / no thread open — the socket stays connected but is viewing nothing.
pipe.hdel(user, socketId);
} else {
pipe.hset(user, socketId, threadId).expire(user, ttl);
}
await pipe.exec();
}
async clearSocket(socketId: string): Promise<void> {
const sock = this.sockKey(socketId);
const userId = await this.redis.get(sock);
const pipe = this.redis.multi().del(sock);
if (userId) pipe.hdel(this.userKey(userId), socketId);
await pipe.exec();
}
async isViewing(userId: string, threadId: string): Promise<boolean> {
const threads = await this.redis.hvals(this.userKey(userId));
return threads.includes(threadId);
}
async onModuleDestroy(): Promise<void> {
await this.redis.quit().catch(() => undefined);
}
}
@@ -33,6 +33,72 @@ describe('DevOpaPort (dev policy plane — membership rules)', () => {
expect(asAdmin.allow).toBe(true); expect(asAdmin.allow).toBe(true);
}); });
it('group rename (thread.update) requires ADMIN; ungoverned threads allow it', async () => {
const asMember = await opa.decide({ action: 'iios.thread.update', membership: 'group', callerRole: 'MEMBER' });
expect(asMember.allow).toBe(false);
expect(asMember.obligations[0]?.reason).toMatch(/admin/);
expect((await opa.decide({ action: 'iios.thread.update', membership: 'group', callerRole: 'ADMIN' })).allow).toBe(true);
expect((await opa.decide({ action: 'iios.thread.update' })).allow).toBe(true); // no membership attr → allow
});
it('group remove requires ADMIN; a member on an ungoverned thread may remove', async () => {
const asMember = await opa.decide({ action: 'iios.thread.participant.remove', membership: 'group', callerRole: 'MEMBER' });
expect(asMember.allow).toBe(false);
expect(asMember.obligations[0]?.reason).toMatch(/admin/);
expect((await opa.decide({ action: 'iios.thread.participant.remove', membership: 'group', callerRole: 'ADMIN' })).allow).toBe(true);
expect((await opa.decide({ action: 'iios.thread.participant.remove', callerRole: 'MEMBER' })).allow).toBe(true);
});
it('the dm cap counts the whole batch, not one add at a time', async () => {
const add = (participantCount: number, targetCount: number) =>
opa.decide({ action: 'iios.thread.participant.add', membership: 'dm', callerRole: 'MEMBER', participantCount, targetCount });
expect((await add(1, 1)).allow).toBe(true); // 1 + 1 = 2, fine
const batch = await add(1, 2); // 1 + 2 = 3 — must be denied even though count is only 1
expect(batch.allow).toBe(false);
expect(batch.obligations[0]?.reason).toMatch(/two people/);
});
it('listing a roster requires membership, except on a public channel', async () => {
const list = (extra: Record<string, unknown>) => opa.decide({ action: 'iios.thread.participant.list', ...extra });
// a private channel / group / dm: members only
const stranger = await list({ membership: 'channel', visibility: 'private', callerRole: undefined });
expect(stranger.allow).toBe(false);
expect(stranger.obligations[0]?.reason).toMatch(/not a member/);
expect((await list({ membership: 'channel', visibility: 'private', callerRole: 'MEMBER' })).allow).toBe(true);
expect((await list({ membership: 'group', callerRole: 'ADMIN' })).allow).toBe(true);
expect((await list({ membership: 'group', callerRole: undefined })).allow).toBe(false);
// a public channel's roster is open, and ungoverned threads are unchanged
expect((await list({ membership: 'channel', visibility: 'public', callerRole: undefined })).allow).toBe(true);
expect((await list({})).allow).toBe(true);
});
it('media upload allows images + docs (incl. html/markdown/csv), denies unknown types and oversize', async () => {
const up = (mime: string, sizeBytes = 1024) => opa.decide({ action: 'iios.media.upload', mime, sizeBytes });
for (const mime of ['image/png', 'video/mp4', 'audio/mpeg', 'application/pdf', 'text/plain', 'text/markdown', 'text/html', 'text/csv']) {
expect((await up(mime)).allow).toBe(true);
}
const bad = await up('application/x-msdownload');
expect(bad.allow).toBe(false);
expect(bad.obligations[0]?.reason).toMatch(/not allowed/);
const big = await up('image/png', 30 * 1024 * 1024);
expect(big.allow).toBe(false);
expect(big.obligations[0]?.reason).toMatch(/too large/);
});
it('a PUBLIC channel allows open self-join; private channel and group do not', async () => {
expect((await opa.decide({ action: 'iios.thread.join', membership: 'channel', visibility: 'public', alreadyMember: false })).allow).toBe(true);
const priv = await opa.decide({ action: 'iios.thread.join', membership: 'channel', visibility: 'private', alreadyMember: false });
expect(priv.allow).toBe(false);
expect((await opa.decide({ action: 'iios.thread.join', membership: 'group', alreadyMember: false })).allow).toBe(false);
// an existing member of a private channel can still (re)open it
expect((await opa.decide({ action: 'iios.thread.join', membership: 'channel', visibility: 'private', alreadyMember: true })).allow).toBe(true);
});
it('discover and leave are allowed (scope fence / member-implied)', async () => {
expect((await opa.decide({ action: 'iios.thread.discover' })).allow).toBe(true);
expect((await opa.decide({ action: 'iios.thread.leave', membership: 'channel' })).allow).toBe(true);
});
it('self-join is governed only on membership threads', async () => { it('self-join is governed only on membership threads', async () => {
// generic / support thread (no membership attr) → open join, unchanged // generic / support thread (no membership attr) → open join, unchanged
expect((await opa.decide({ action: 'iios.thread.join', alreadyMember: false })).allow).toBe(true); expect((await opa.decide({ action: 'iios.thread.join', alreadyMember: false })).allow).toBe(true);

Some files were not shown because too many files have changed in this diff Show More