32 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
64 changed files with 2586 additions and 145 deletions
+27
View File
@@ -6,3 +6,30 @@ DATABASE_URL="postgresql://iios:iios@localhost:5434/iios?schema=public"
JWT_SECRET="dev-only-change-me"
# Per-app HS256 secrets, JSON map keyed by appId (the `session` platform port)
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
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:
iios_postgres_data:
iios_meili_data:
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@insignia/iios-kernel-client",
"version": "0.1.4",
"version": "0.1.6",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
+4
View File
@@ -37,7 +37,11 @@ export interface OpenThreadResult {
export interface ReceiptEvent {
interactionId: string;
/** The reader's IIOS actor UUID — an internal id, NOT comparable to a caller's userId. */
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';
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@insignia/iios-messaging-ui",
"version": "0.1.0",
"version": "0.1.14",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
+28
View File
@@ -1,5 +1,6 @@
import type {
Attachment,
BulkAddResult,
ChannelSummary,
Conversation,
CreateChannelInput,
@@ -39,6 +40,15 @@ export interface MessagingAdapter {
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.
*
@@ -80,4 +90,22 @@ export interface MessagingAdapter {
/** 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>;
}
@@ -88,6 +88,11 @@ export class MockInboxAdapter implements InboxAdapter {
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];
}
@@ -1,6 +1,7 @@
import type { MessagingAdapter } from '../adapter';
import type {
Attachment,
BulkAddResult,
ChannelSummary,
ChannelVisibility,
Conversation,
@@ -163,6 +164,37 @@ export class MockAdapter implements MessagingAdapter {
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) {
@@ -238,8 +270,9 @@ export class MockAdapter implements MessagingAdapter {
// No-op: nobody is typing back in a mock.
}
async markRead(): Promise<void> {
// No-op: the mock has no second party to report a read.
/** 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 {
@@ -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()));
});
});
@@ -1,4 +1,4 @@
import { useMemo, useRef, useState, type ChangeEvent, type FormEvent, type KeyboardEvent } from 'react';
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type FormEvent, type KeyboardEvent } from 'react';
import {
SPECIAL_MENTIONS,
insertMention,
@@ -13,6 +13,17 @@ interface Suggestion {
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).
@@ -24,7 +35,7 @@ export function Composer({
onSend,
onTyping,
parentInteractionId,
placeholder = 'Type a message… @ to mention',
placeholder = 'Type a message… @ to mention, *bold*',
}: {
members: Person[];
canUpload: boolean;
@@ -38,7 +49,41 @@ export function Composer({
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[]>(() => {
@@ -59,12 +104,14 @@ export function Composer({
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);
}
}
@@ -90,10 +137,26 @@ export function Composer({
}
}
function onKeyDown(e: KeyboardEvent<HTMLInputElement>): void {
if (showSuggest && e.key === 'Enter') {
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();
}
}
@@ -110,7 +173,11 @@ export function Composer({
))}
</ul>
) : null}
{staged ? (
{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">
@@ -118,6 +185,13 @@ export function Composer({
</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 ? (
<>
@@ -127,11 +201,18 @@ export function Composer({
</button>
</>
) : null}
<input
className="miu-input"
<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?.();
@@ -1,4 +1,5 @@
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 {
@@ -37,7 +38,7 @@ export function ConversationList({
</span>
<span className="miu-convrow-main">
<span className="miu-convrow-title">{c.title}</span>
{c.lastMessage ? <span className="miu-convrow-preview">{c.lastMessage}</span> : null}
{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`}>
@@ -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>
);
}
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { highlightMentions } from '../mentions';
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';
@@ -7,6 +8,20 @@ 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 (
@@ -43,37 +58,43 @@ export function MessageItem({
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 ? highlightMentions(m.text, memberNames) : null}
{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 type="button" className="miu-react-btn" title="React" onClick={() => setPickerOpen((p) => !p)}>
<button ref={reactBtnRef} type="button" className="miu-react-btn" title="React" onClick={() => setPickerOpen((p) => !p)}>
🙂
</button>
{pickerOpen ? (
<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 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}
@@ -12,7 +12,7 @@ import { ThreadPane } from './thread-pane';
* 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() {
export function Messenger({ focusThreadId }: { focusThreadId?: string | null } = {}) {
const { conversations, loading, error, refetch } = useConversations();
const adapter = useAdapter();
const channelsSupported = typeof adapter.browseChannels === 'function';
@@ -32,8 +32,40 @@ export function Messenger() {
// 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);
@@ -104,7 +136,16 @@ export function Messenger() {
}}
/>
) : (
<Thread threadId={selected} activeRootId={activeRoot} onOpenThread={setActiveRoot} />
<Thread
threadId={selected}
conversation={selectedConversation}
activeRootId={activeRoot}
onOpenThread={setActiveRoot}
onLeft={() => {
refetch();
setSelected(null);
}}
/>
)}
</section>
@@ -8,7 +8,7 @@ const THEME_VARS = [
'--miu-text', '--miu-muted', '--miu-accent', '--miu-accent-text', '--miu-radius',
] as const;
function copyThemeVars(): Record<string, string> {
export function copyThemeVars(): Record<string, string> {
if (typeof document === 'undefined') return {};
const src = document.querySelector('.miu-messenger, .miu-inbox');
if (!src) return {};
@@ -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,
);
}
@@ -1,8 +1,10 @@
import { useMemo } from 'react';
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
@@ -11,16 +13,24 @@ import { MessageItem } from './message-item';
*/
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(() => {
@@ -35,6 +45,19 @@ export function Thread({
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}
@@ -56,6 +79,16 @@ export function Thread({
</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>
);
}
@@ -158,3 +158,39 @@ describe('useMessages', () => {
}
});
});
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);
});
});
@@ -160,13 +160,19 @@ export function useMessages(threadId: string | null): MessagesState {
if (threadId) adapter.sendTyping(threadId);
}, [adapter, threadId]);
// The newest acknowledged (non-pending) message id — what we report as read.
// 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--) {
if (!raw[i]!.pending) return raw[i]!.id;
const m = raw[i]!;
if (m.pending || isOwnMessage(m, currentActorId)) continue;
return m.id;
}
return null;
}, [raw]);
}, [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.
@@ -22,6 +22,10 @@ export interface InboxAdapter {
/** 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[]>;
+20 -1
View File
@@ -60,6 +60,8 @@ export interface MailThreadState {
reply: (content: string, attachment?: MailAttachment) => Promise<void>;
canAttach: boolean;
upload: (file: File) => Promise<MailAttachment>;
canDownload: boolean;
download: (attachment: MailAttachment) => Promise<string>;
refetch: () => void;
}
@@ -113,8 +115,25 @@ export function useMailThread(threadId: string | null): MailThreadState {
},
[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, refetch };
return {
messages,
loading,
error,
reply,
canAttach: typeof adapter.uploadAttachment === 'function',
upload,
canDownload: typeof adapter.downloadAttachment === 'function',
download,
refetch,
};
}
export interface ComposeState {
+49 -6
View File
@@ -32,17 +32,30 @@ const timeOf = (iso?: string): string => {
};
/** The unified inbox: work items + mail in one list; click a threaded row to read + reply. */
export function Inbox() {
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]);
}, [items, selectedId, focusThreadId]);
const selected = items.find((i) => i.id === selectedId) ?? null;
@@ -117,19 +130,30 @@ function Detail({ item, onTransition }: { item: InboxItem; onTransition: (state:
/** 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 } = useMailThread(threadId);
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));
@@ -137,6 +161,7 @@ export function MailReader({ threadId, subject }: { threadId: string; subject: s
setAttachErr(err instanceof Error ? err.message : String(err));
} finally {
setAttaching(false);
setUploadingName(null);
}
}
@@ -176,14 +201,24 @@ export function MailReader({ threadId, subject }: { threadId: string; subject: s
<div className="miu-mail-text">{m.text}</div>
) : null}
{m.attachment ? (
<span className="miu-attach-chip">📎 {m.attachment.filename ?? 'attachment'}{m.attachment.sizeBytes ? ` · ${fmtBytes(m.attachment.sizeBytes)}` : ''}</span>
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}
{pending ? (
{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>
@@ -217,6 +252,7 @@ function ComposeModal({ onClose, onSent }: { onClose: () => void; onSent: () =>
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()));
@@ -227,6 +263,7 @@ function ComposeModal({ onClose, onSent }: { onClose: () => void; onSent: () =>
e.target.value = '';
if (!file || attachments.length >= 10) return;
setAttaching(true);
setUploadingName(file.name);
setErr(null);
try {
const ref = await compose.upload(file);
@@ -235,6 +272,7 @@ function ComposeModal({ onClose, onSent }: { onClose: () => void; onSent: () =>
setErr(e2 instanceof Error ? e2.message : String(e2));
} finally {
setAttaching(false);
setUploadingName(null);
}
}
@@ -308,9 +346,14 @@ function ComposeModal({ onClose, onSent }: { onClose: () => void; onSent: () =>
<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}>
{attaching ? 'Uploading…' : '📎 Attach'}
📎 Attach
</button>
</div>
</div>
+3
View File
@@ -9,6 +9,8 @@ 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';
@@ -30,6 +32,7 @@ export type { MessagesState, UiMessage } from './hooks/use-messages';
export type { ChannelsState } from './hooks/use-channels';
export type {
Attachment,
BulkAddResult,
ChannelSummary,
ChannelVisibility,
Conversation,
@@ -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];
}
+195 -15
View File
@@ -10,6 +10,8 @@
--miu-accent: #fda913;
--miu-accent-text: #1a1206;
--miu-radius: 12px;
/* Collapsed composer height — textarea, attach and send all use this so they never diverge. */
--miu-composer-h: 38px;
display: flex;
height: 100%;
@@ -162,6 +164,80 @@
gap: 2px;
flex-shrink: 0;
}
.miu-msg-time {
font-size: 11px;
color: var(--miu-muted);
flex-shrink: 0;
white-space: nowrap;
}
/* thread header + group/channel settings */
.miu-thread-head {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 14px;
border-bottom: 1px solid var(--miu-border);
}
.miu-thread-title {
font-weight: 700;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.miu-thread-settings {
flex-shrink: 0;
border: none;
background: none;
color: var(--miu-muted);
cursor: pointer;
font-size: 16px;
line-height: 1;
padding: 4px;
border-radius: 8px;
}
.miu-thread-settings:hover {
background: var(--miu-panel-2);
color: var(--miu-text);
}
.miu-settings-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 240px;
overflow-y: auto;
}
.miu-settings-member {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
border: 1px solid var(--miu-border);
border-radius: 10px;
background: var(--miu-panel);
color: var(--miu-text);
font-size: 13px;
text-align: left;
}
.miu-settings-member.is-add {
cursor: pointer;
}
.miu-settings-member.is-add:hover {
border-color: var(--miu-accent);
}
.miu-settings-name {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.miu-settings-plus {
color: var(--miu-accent);
font-weight: 700;
}
.miu-thread-link {
align-self: flex-start;
margin-top: 3px;
@@ -196,13 +272,13 @@
.miu-react-btn:hover {
opacity: 1;
}
/* Portaled to <body> via .miu-popover so it's never clipped by a scroll container. */
.miu-popover {
position: fixed;
z-index: 2147483000;
transform: translateX(-100%); /* right-align the picker's right edge to the anchor */
}
.miu-react-picker {
position: absolute;
/* Open downward + right-aligned so it never clips against the top of the scroll area
(the reported bug) or spills past the right edge. */
top: 100%;
right: 0;
margin-top: 4px;
display: flex;
gap: 2px;
padding: 4px;
@@ -210,14 +286,9 @@
border: 1px solid var(--miu-border);
background: var(--miu-panel);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
z-index: 5;
width: max-content;
}
/* On my own (right-aligned) messages, anchor the picker to the left instead so it stays in view. */
.miu-msg.is-mine .miu-react-picker {
right: auto;
left: 0;
}
.miu-react-emoji {
border: none;
background: none;
@@ -302,13 +373,17 @@
.miu-composer-row {
display: flex;
gap: 8px;
/* Pin the controls to the bottom so a growing textarea never stretches them (WhatsApp). */
align-items: flex-end;
}
.miu-file-input {
display: none;
}
.miu-attach-btn {
flex-shrink: 0;
width: 38px;
box-sizing: border-box;
width: var(--miu-composer-h);
height: var(--miu-composer-h);
border-radius: 10px;
border: 1px solid var(--miu-border);
background: var(--miu-panel);
@@ -353,13 +428,40 @@
white-space: nowrap;
}
.miu-mail-msg .miu-attach-chip {
margin-top: 6px;
margin: 8px 12px 12px;
}
button.miu-attach-dl {
cursor: pointer;
color: var(--miu-text);
}
button.miu-attach-dl:hover {
border-color: var(--miu-accent);
color: var(--miu-accent);
}
.miu-attach-pending {
display: inline-flex;
align-items: center;
gap: 6px;
}
.miu-attach-chip.is-uploading,
.miu-staged.is-uploading {
color: var(--miu-muted);
}
.miu-spinner {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid var(--miu-border);
border-top-color: var(--miu-accent);
border-radius: 50%;
animation: miu-spin 0.7s linear infinite;
}
@keyframes miu-spin {
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.miu-spinner { animation-duration: 2s; }
}
.miu-attach-x {
border: none;
background: none;
@@ -419,6 +521,78 @@
.miu-input:focus {
border-color: var(--miu-accent);
}
/* Chat box: one row by default, grows with content (JS sets height), then scrolls.
border-box so min/max-height are the REAL height — under the default content-box the padding
and border are added on top, which made a "38px" box render ~58px and dragged the row with it. */
/* Composer box only. NOTE: .miu-textarea is already taken by the inbox mail composer further
down (resize: vertical; min-height: 90px) — sharing it made that rule win and blow this up. */
.miu-composer-box {
box-sizing: border-box;
resize: none;
/* Collapsed height must equal the single-line <input> this replaced: 20px of line + 16px
padding + 2px border = 38px. Padding is tightened from .miu-input's 9px so a 14px/1.4 line
fits the content box exactly — at 9px it overflowed and forced a scrollbar. */
padding: 8px 12px;
line-height: 1.4;
min-height: var(--miu-composer-h);
max-height: 160px;
overflow-y: auto;
font-family: inherit;
}
.miu-format-bar {
display: flex;
gap: 2px;
padding: 0 2px 4px;
}
.miu-format-btn {
min-width: 26px;
height: 24px;
padding: 0 6px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--miu-muted);
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.miu-format-btn:hover {
background: var(--miu-panel-2);
color: var(--miu-text);
}
/* ── Rendered message formatting ── */
.miu-code {
padding: 1px 5px;
border-radius: 5px;
background: var(--miu-panel-2);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.92em;
}
.miu-code-block {
margin: 6px 0 2px;
padding: 8px 10px;
border-radius: 8px;
background: var(--miu-panel-2);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.92em;
white-space: pre-wrap;
overflow-x: auto;
}
.miu-link {
color: var(--miu-accent);
text-decoration: underline;
}
.miu-msg.is-mine .miu-bubble .miu-link {
color: var(--miu-accent-text);
}
/* On your own (accent-filled) bubble the panel background would sit dark-on-dark against the dark
accent text — tint the bubble instead so the chip reads on any accent colour. */
.miu-msg.is-mine .miu-bubble .miu-code,
.miu-msg.is-mine .miu-bubble .miu-code-block {
background: rgba(0, 0, 0, 0.16);
color: var(--miu-accent-text);
}
.miu-send {
padding: 0 16px;
border-radius: 10px;
@@ -428,6 +602,11 @@
color: var(--miu-accent-text);
background: var(--miu-accent);
}
.miu-composer .miu-send {
box-sizing: border-box;
flex-shrink: 0;
height: var(--miu-composer-h);
}
.miu-send:disabled {
opacity: 0.5;
cursor: not-allowed;
@@ -763,7 +942,7 @@
.miu-detail {
display: flex;
flex-direction: column;
height: 100%;
flex: 1;
min-height: 0;
}
.miu-detail-actions {
@@ -790,7 +969,7 @@
.miu-mail {
display: flex;
flex-direction: column;
height: 100%;
flex: 1;
min-height: 0;
}
.miu-mail-head {
@@ -809,6 +988,7 @@
gap: 12px;
}
.miu-mail-msg {
flex: 0 0 auto; /* don't let the flex column shrink cards — they'd clip their own content */
border: 1px solid var(--miu-border);
border-radius: var(--miu-radius);
background: var(--miu-panel);
+14
View File
@@ -24,6 +24,16 @@ export interface Conversation {
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;
@@ -48,9 +58,13 @@ export interface Reaction {
}
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 {
@@ -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",
"jsonwebtoken": "^9.0.3",
"jwks-rsa": "^4.1.0",
"meilisearch": "^0.45.0",
"nodemailer": "^9.0.3",
"prisma": "^6.2.1",
"reflect-metadata": "^0.2.2",
@@ -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");
@@ -543,6 +543,22 @@ model IiosMessagePart {
@@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.
model IiosOutboxEvent {
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 { MessageModule } from './messaging/message.module';
import { InboxModule } from './inbox/inbox.module';
import { SearchModule } from './search/search.module';
import { TemplateModule } from './templates/template.module';
import { MailModule } from './mail/mail.module';
import { MediaModule } from './media/media.module';
@@ -39,6 +40,7 @@ import { DevController } from './dev/dev.controller';
ThreadsModule,
MessageModule,
InboxModule,
SearchModule,
TemplateModule,
MailModule,
MediaModule,
@@ -3,7 +3,7 @@ import type { CapabilityProvider } from '@insignia/iios-contracts';
import { SandboxProvider } from './sandbox.provider';
import { HttpProvider } from './http.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';
@@ -36,11 +36,15 @@ export class CapabilityProviderRegistry {
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
// 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();
if (smtp) {
const smtpCreds = credKeyFromEnv() && this.credentials ? this.credentials : undefined;
if (smtp || smtpCreds) {
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.
@@ -2,9 +2,34 @@ import { BadRequestException, Body, Controller, Get, Headers, Param, Put } from
import { SessionVerifier } from '../platform/session.verifier';
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
import { ProviderCredentialService } from './provider-credential.service';
import { TwilioCredentialsDto } from './provider-credential.dto';
const SUPPORTED = new Set(['TWILIO_SMS']);
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
@@ -22,16 +47,13 @@ export class ProviderCredentialController {
@Put(':providerType/credentials')
async put(
@Param('providerType') providerType: string,
@Body() body: TwilioCredentialsDto,
@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, {
accountSid: body.accountSid,
authToken: body.authToken,
fromNumber: body.fromNumber,
});
await this.credentials.upsert(scope.id, providerType, config);
return this.credentials.status(scope.id, providerType);
}
@@ -1,11 +0,0 @@
import { IsNotEmpty, IsString } from 'class-validator';
/**
* PUT /v1/providers/TWILIO_SMS/credentials a tenant's own Twilio credentials. Step 1 supports
* TWILIO_SMS only; when a second provider (SMTP, ) is added, switch to a per-type validated body.
*/
export class TwilioCredentialsDto {
@IsString() @IsNotEmpty() accountSid!: string;
@IsString() @IsNotEmpty() authToken!: string;
@IsString() @IsNotEmpty() fromNumber!: string;
}
@@ -18,6 +18,9 @@ function hintsFor(providerType: string, config: ProviderConfig): Record<string,
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 {};
}
@@ -133,3 +133,42 @@ describe('storageResolver (media StoragePort → AttachmentResolver)', () => {
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 {
subject?: string;
text?: string;
@@ -107,10 +130,12 @@ export class SmtpProvider implements CapabilityProvider {
private readonly makeTransport: (id: SmtpIdentity) => MailTransport;
constructor(
private readonly primary: SmtpIdentity,
private readonly primary: SmtpIdentity | undefined,
private readonly fallback?: SmtpIdentity,
makeTransport?: (id: SmtpIdentity) => MailTransport,
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;
}
@@ -119,6 +144,13 @@ export class SmtpProvider implements CapabilityProvider {
const started = Date.now();
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
// its file; a FAILED command retries instead. Resolved once so a fallback retry doesn't re-fetch.
let attachments: MailAttachment[] | undefined;
@@ -145,13 +177,13 @@ export class SmtpProvider implements CapabilityProvider {
});
try {
const info = await attempt(this.primary);
const info = await attempt(identity);
return { providerRef: info.messageId, outcome: 'SENT', latencyMs: Date.now() - started };
} catch (err) {
// Retry via the fallback identity ONLY if the primary never got the message accepted.
if (this.fallback && isPreAcceptanceFailure(err)) {
if (fallback && isPreAcceptanceFailure(err)) {
try {
const info = await attempt(this.fallback);
const info = await attempt(fallback);
return { providerRef: `fallback:${info.messageId}`, outcome: 'SENT', latencyMs: Date.now() - started };
} catch (err2) {
return { providerRef: `smtp-error-${randomUUID().slice(0, 8)}`, outcome: 'FAILED', errorCode: codeOf(err2), latencyMs: Date.now() - started };
@@ -13,9 +13,11 @@ const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/i
const prisma = new PrismaClient({ datasources: { db: { url } } });
const asService = prisma as unknown as PrismaService;
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 DAY_MS = 24 * 60 * 60 * 1000;
// point storage at an isolated temp dir for the test run
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();
});
});
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 { 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 type { IiosPlatformPorts } from '@insignia/iios-contracts';
import { PLATFORM_PORTS } from '../platform/platform-ports';
import { PrismaService } from '../prisma/prisma.service';
import { decideOrThrow } from '../platform/fail-closed';
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
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 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.
*/
@Injectable()
export class MediaService {
export class MediaService implements OnModuleInit, OnModuleDestroy {
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(/\/$/, '');
/** 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(
@Inject(STORAGE_PORT) private readonly storage: StoragePort,
@Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts,
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. */
async presignUpload(
principal: MessagePrincipal,
@@ -44,9 +66,58 @@ export class MediaService {
const t = this.verify<UploadToken>(token, 'put');
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);
// 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 };
}
/**
* 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). */
async presignDownload(principal: MessagePrincipal, contentRef: string, mime = 'application/octet-stream'): Promise<{ url: string }> {
const scope = await this.actors.resolveScope(principal);
@@ -5,7 +5,7 @@ import { MessageGateway } from './message.gateway';
import type { MessageService, MessagePrincipal } from './message.service';
import { SessionVerifier } from '../platform/session.verifier';
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 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,
session,
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 { Logger } from '@nestjs/common';
import { Inject, Logger } from '@nestjs/common';
import {
ConnectedSocket,
MessageBody,
@@ -16,7 +16,7 @@ import { logJson } from '../observability/logger';
import { MessageService, type MessagePrincipal } from './message.service';
import { SessionVerifier } from '../platform/session.verifier';
import { OutboxBus } from '../outbox/outbox.bus';
import { PresenceService } from '../notifications/presence.service';
import { PRESENCE_PORT, type PresencePort } from '../notifications/presence.port';
interface SocketState {
principal: MessagePrincipal;
@@ -39,7 +39,7 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection, OnGat
private readonly messages: MessageService,
private readonly session: SessionVerifier,
private readonly bus: OutboxBus,
private readonly presence: PresenceService,
@Inject(PRESENCE_PORT) private readonly presence: PresencePort,
) {}
afterInit(): void {
@@ -77,7 +77,8 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection, OnGat
}
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. */
@@ -85,7 +86,9 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection, OnGat
focusThread(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string | null }): void {
const state = client.data as SocketState | undefined;
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')
@@ -156,7 +159,9 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection, OnGat
async read(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string; interactionId: string }) {
const { principal } = client.data as SocketState;
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 };
}
@@ -164,7 +169,9 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection, OnGat
async delivered(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string; interactionId: string }) {
const { principal } = client.data as SocketState;
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 };
}
@@ -3,10 +3,21 @@ import { MessageService } from './message.service';
import { MessageGateway } from './message.gateway';
import { OutboxModule } from '../outbox/outbox.module';
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({
imports: [OutboxModule],
providers: [MessageService, MessageGateway, PresenceService],
exports: [MessageService, PresenceService],
providers: [MessageService, MessageGateway, presenceProvider],
exports: [MessageService, PRESENCE_PORT],
})
export class MessageModule {}
@@ -1,5 +1,5 @@
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 { CloudEvent, IIOS_EVENTS, IiosPlatformPorts } from '@insignia/iios-contracts';
import { PrismaService } from '../prisma/prisma.service';
@@ -74,6 +74,10 @@ export interface OpenThreadResult {
*/
@Injectable()
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(
private readonly prisma: PrismaService,
@Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts,
@@ -167,6 +171,76 @@ export class MessageService {
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
@@ -224,7 +298,19 @@ export class MessageService {
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');
await decideOrThrow(this.ports, { action: 'iios.thread.read', threadId, scopeId: thread.scopeId });
// 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 } } },
@@ -146,6 +146,61 @@ describe('Governed membership + replies (v1.1, policy-enforced)', () => {
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 () => {
const s = gov();
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' } });
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') {
const deliver = vi.fn().mockResolvedValue(deliverResult);
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 m.send(threadId, alice, { content: 'hi bob' }, 'k1');
const { proj, deliver } = makeProjector();
await proj.onMessageSent((await sentEvents())[0]!);
await proj.onEvent((await sentEvents())[0]!);
expect(deliver).toHaveBeenCalledOnce();
expect(deliver.mock.calls[0][0].endpoint).toContain('push/bob');
});
@@ -63,7 +75,7 @@ describe('NotificationProjector', () => {
await seedSub('bob');
await m.send(threadId, alice, { content: 'hello all' }, 'k1');
const { proj, deliver } = makeProjector();
await proj.onMessageSent((await sentEvents())[0]!);
await proj.onEvent((await sentEvents())[0]!);
expect(deliver).not.toHaveBeenCalled();
});
@@ -74,7 +86,7 @@ describe('NotificationProjector', () => {
await seedSub('bob');
await m.send(threadId, alice, { content: 'hey @bob' }, 'k1', undefined, undefined, ['bob']);
const { proj, deliver } = makeProjector();
await proj.onMessageSent((await sentEvents())[0]!);
await proj.onEvent((await sentEvents())[0]!);
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)
const { proj, deliver } = makeProjector();
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.mock.calls[0][0].endpoint).toContain('push/bob');
});
@@ -102,7 +114,7 @@ describe('NotificationProjector', () => {
const presence = new PresenceService();
presence.setFocus('sockB', 'bob', threadId);
const { proj, deliver } = makeProjector(presence);
await proj.onMessageSent((await sentEvents())[0]!);
await proj.onEvent((await sentEvents())[0]!);
expect(deliver).not.toHaveBeenCalled();
});
@@ -114,7 +126,31 @@ describe('NotificationProjector', () => {
await prisma.iiosThreadParticipant.update({ where: { threadId_actorId: { threadId, actorId: bobActorId } }, data: { muted: true } });
await m.send(threadId, alice, { content: 'hi' }, 'k1');
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();
});
@@ -125,7 +161,7 @@ describe('NotificationProjector', () => {
await seedSub('bob');
await m.send(threadId, alice, { content: 'hi' }, 'k1');
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);
});
});
@@ -4,7 +4,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { OutboxBus } from '../outbox/outbox.bus';
import { DlqService } from '../outbox/dlq.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';
interface MsgData {
@@ -14,14 +14,22 @@ interface MsgData {
mentions?: string[];
}
interface NormalizedData {
interactionId: string;
threadId: string;
kind?: string;
}
/**
* Turns `message.sent` into push notifications for absent recipients. Three gates:
* 1. policy DM always; group only if @mentioned or a reply to that recipient
* 2. presence skip if the recipient is currently focused on that thread
* 3. mute skip if the recipient muted the thread
* Turns messenger sends AND inbound mail into push notifications for absent recipients:
* - `message.sent` messenger policy (DM always; group only if @mentioned or reply-to-you)
* - `interaction.normalized` (kind EMAIL) mail always notifies the recipient (a directed 1:1)
* 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.
* Idempotent per event id (same pattern as InboxProjector). Generic: DM-vs-group is read
* from the opaque `membership` thread attribute here in the notification *policy*, not the kernel.
* Idempotent per event id (same pattern as InboxProjector). Generic: DM-vs-group / mail is read
* from opaque thread attributes here in the notification *policy*, not the kernel.
*/
@Injectable()
export class NotificationProjector implements OnModuleInit {
@@ -32,36 +40,63 @@ export class NotificationProjector implements OnModuleInit {
private readonly bus: OutboxBus,
private readonly dlq: DlqService,
private readonly cursor: ProjectionCursorService,
private readonly presence: PresenceService,
@Inject(PRESENCE_PORT) private readonly presence: PresencePort,
@Inject(NOTIFICATION_PORT) private readonly port: NotificationPort,
) {}
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) =>
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
await this.apply(event);
await this.cursor.advance(this.consumer, event);
}
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 thread = await this.prisma.iiosThread.findUnique({ where: { id: data.threadId } });
if (!thread) return;
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({
where: { id: data.interactionId },
include: { parts: { where: { kind: 'TEXT' }, take: 1 }, actor: { include: { sourceHandle: true } } },
});
const senderName = interaction?.actor?.sourceHandle?.externalId ?? 'Someone';
const body = interaction?.parts[0]?.bodyText ?? 'sent an attachment';
const mentions = data.mentions ?? [];
// Parent author (for reply-to-you) — one lookup.
let parentAuthorActorId: string | null = null;
@@ -85,10 +120,10 @@ export class NotificationProjector implements OnModuleInit {
// gate 1 — policy
const mentioned = userId != null && mentions.includes(userId);
const repliedToMe = parentAuthorActorId != null && parentAuthorActorId === p.actorId;
if (!(membership === 'dm' || mentioned || repliedToMe)) continue;
if (!(alwaysNotify || mentioned || repliedToMe)) continue;
// 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
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';
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();
expect(p.isViewing('alice', 'T1')).toBe(false);
p.setFocus('sock1', 'alice', 'T1');
expect(p.isViewing('alice', 'T1')).toBe(true);
expect(p.isViewing('alice', 'T2')).toBe(false); // joined-elsewhere ≠ viewing
p.setFocus('sock1', 'alice', 'T2'); // moved focus
expect(p.isViewing('alice', 'T1')).toBe(false);
expect(p.isViewing('alice', 'T2')).toBe(true);
p.clearSocket('sock1');
expect(p.isViewing('alice', 'T2')).toBe(false);
expect(await p.isViewing('alice', 'T1')).toBe(false);
await p.setFocus('sock1', 'alice', 'T1');
expect(await p.isViewing('alice', 'T1')).toBe(true);
expect(await p.isViewing('alice', 'T2')).toBe(false); // joined-elsewhere ≠ viewing
await p.setFocus('sock1', 'alice', 'T2'); // moved focus
expect(await p.isViewing('alice', 'T1')).toBe(false);
expect(await p.isViewing('alice', 'T2')).toBe(true);
await p.clearSocket('sock1');
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();
p.setFocus('sockA', 'bob', 'T9');
p.setFocus('sockB', 'bob', null); // a second tab, no focus
expect(p.isViewing('bob', 'T9')).toBe(true);
await p.setFocus('sockA', 'bob', 'T9');
await p.setFocus('sockB', 'bob', null); // a second tab, no focus
expect(await p.isViewing('bob', 'T9')).toBe(true);
});
});
@@ -1,24 +1,28 @@
import { Injectable } from '@nestjs/common';
import type { PresencePort } from './presence.port';
/**
* 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
* 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()
export class PresenceService {
export class PresenceService implements PresencePort {
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 });
}
clearSocket(socketId: string): void {
async clearSocket(socketId: string): Promise<void> {
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;
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);
}
}
@@ -49,6 +49,29 @@ describe('DevOpaPort (dev policy plane — membership rules)', () => {
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']) {
@@ -12,6 +12,7 @@ export interface OpaInput {
membership?: string; // app-set generic thread attribute: 'dm' | 'group' | 'channel'
visibility?: string; // 'public' | 'private' (channels)
participantCount?: number;
targetCount?: number; // how many participants a single add call is inviting (1 for a single add)
callerRole?: string; // 'MEMBER' | 'ADMIN'
alreadyMember?: boolean;
isMember?: boolean;
@@ -38,11 +39,23 @@ export class DevOpaPort {
switch (i.action) {
case 'iios.thread.participant.add': {
const count = i.participantCount ?? 0;
if (i.membership === 'dm' && count >= 2) return deny('a direct message is limited to two people');
// `targetCount` is how many are being added in this call (1 for a single add), so the dm cap
// holds for a bulk import too instead of being evaluated one-at-a-time.
if (i.membership === 'dm' && count + (i.targetCount ?? 1) > 2) return deny('a direct message is limited to two people');
if (i.callerRole !== 'MEMBER' && i.callerRole !== 'ADMIN') return deny('only a member can add participants');
if (i.membership === 'group' && i.callerRole !== 'ADMIN') return deny('only a group admin can add or remove members');
return allow();
}
case 'iios.thread.participant.list': {
// Reading a thread's ROSTER. A public channel is open (it is discoverable anyway), but every
// other membership thread — dm, group, private channel — requires you to be a member, or any
// caller in the scope could enumerate a private channel's members by id. Ungoverned threads
// (no membership attribute, e.g. support) keep the previous open behaviour.
if (!i.membership) return allow();
if (i.membership === 'channel' && i.visibility === 'public') return allow();
if (i.callerRole === 'MEMBER' || i.callerRole === 'ADMIN') return allow();
return deny('you are not a member of this thread');
}
case 'iios.thread.participant.remove': {
// Same governance as add: for a group, only an ADMIN removes members. Ungoverned
// (no membership attr) threads allow removal by any member.
@@ -0,0 +1,101 @@
import { Logger } from '@nestjs/common';
import { MeiliSearch } from 'meilisearch';
import type { MessageSearchPort, SearchDoc, SearchHit } from './message-search.port';
const INDEX = 'iios_messages';
/** A no-op search port — bound when Meilisearch isn't configured, so search degrades to empty. */
export const noopMessageSearch: MessageSearchPort = {
ready: () => false,
index: async () => undefined,
remove: async () => undefined,
search: async () => [],
};
/**
* Meilisearch-backed message search. Configured via MEILI_URL (+ optional MEILI_KEY). The index is
* created + configured on first use (searchable text/subject; filterable scopeId/threadId/source;
* sortable at). Filtering by scopeId AND threadId is the permission fence the caller only ever
* passes thread ids they belong to.
*/
export class MeiliMessageSearch implements MessageSearchPort {
private readonly client: MeiliSearch;
private readonly logger = new Logger(MeiliMessageSearch.name);
private settingsReady?: Promise<void>;
constructor(host: string, apiKey?: string) {
this.client = new MeiliSearch({ host, ...(apiKey ? { apiKey } : {}) });
}
ready(): boolean {
return true;
}
/** Idempotently ensure the index + its attribute settings exist (runs once, memoised). */
private ensure(): Promise<void> {
if (!this.settingsReady) {
this.settingsReady = (async () => {
await this.client.createIndex(INDEX, { primaryKey: 'id' }).catch(() => undefined);
await this.client.index(INDEX).updateSettings({
searchableAttributes: ['text', 'subject'],
filterableAttributes: ['scopeId', 'threadId', 'source'],
sortableAttributes: ['at'],
});
})().catch((err) => {
this.settingsReady = undefined; // let a later call retry
throw err;
});
}
return this.settingsReady;
}
async index(docs: SearchDoc[]): Promise<void> {
if (docs.length === 0) return;
await this.ensure();
await this.client.index(INDEX).addDocuments(docs, { primaryKey: 'id' });
}
async remove(ids: string[]): Promise<void> {
if (ids.length === 0) return;
await this.client.index(INDEX).deleteDocuments(ids);
}
async search(scopeId: string, threadIds: string[], query: string, limit: number): Promise<SearchHit[]> {
if (threadIds.length === 0 || !query.trim()) return [];
await this.ensure();
const quoted = threadIds.map((t) => JSON.stringify(t)).join(', ');
const res = await this.client.index(INDEX).search(query, {
filter: `scopeId = ${JSON.stringify(scopeId)} AND threadId IN [${quoted}]`,
limit,
sort: ['at:desc'],
attributesToHighlight: ['text'],
highlightPreTag: '<em>',
highlightPostTag: '</em>',
attributesToCrop: ['text'],
cropLength: 30,
});
return res.hits.map((h) => {
const doc = h as unknown as SearchDoc & { _formatted?: { text?: string } };
return {
id: doc.id,
threadId: doc.threadId,
text: doc.text,
subject: doc.subject ?? null,
source: doc.source ?? null,
at: doc.at,
snippet: doc._formatted?.text ?? doc.text,
};
});
}
}
/** Build the search port from env: MEILI_URL set → Meilisearch; else the no-op. */
export function messageSearchFromEnv(env: NodeJS.ProcessEnv = process.env): MessageSearchPort {
const host = env.MEILI_URL?.trim();
if (!host) {
new Logger('MessageSearch').log('MEILI_URL unset — message search disabled (no-op)');
return noopMessageSearch;
}
new Logger('MessageSearch').log(`using Meilisearch @ ${host}`);
return new MeiliMessageSearch(host, env.MEILI_KEY?.trim() || undefined);
}
@@ -0,0 +1,39 @@
/** One indexed message document. `at` is epoch-ms so Meilisearch can sort recency-first. */
export interface SearchDoc {
id: string; // interactionId
scopeId: string;
threadId: string;
text: string;
subject: string | null;
source: string | null; // opaque app source tag (e.g. crm-messenger / crm-mail) — drives the surface
actorId: string | null;
at: number;
}
/** A search hit with a highlighted snippet. */
export interface SearchHit {
id: string;
threadId: string;
text: string;
subject: string | null;
source: string | null;
at: number;
/** The match with `<em>…</em>` around the query terms (from the engine's highlighter). */
snippet: string;
}
/**
* The message-search seam. IIOS owns egress-style search: a message index + a permission-scoped
* query. The concrete engine (Meilisearch) sits behind this; an unconfigured deployment binds a
* no-op so search degrades to empty rather than erroring.
*/
export interface MessageSearchPort {
/** True only when a real engine is configured (else index/search are inert). */
ready(): boolean;
index(docs: SearchDoc[]): Promise<void>;
remove(ids: string[]): Promise<void>;
/** Search WITHIN the given scope and thread set only (the permission fence — enforced here). */
search(scopeId: string, threadIds: string[], query: string, limit: number): Promise<SearchHit[]>;
}
export const MESSAGE_SEARCH_PORT = Symbol('MESSAGE_SEARCH_PORT');
@@ -0,0 +1,36 @@
import { BadRequestException, Body, Controller, Headers, Post } from '@nestjs/common';
import { SessionVerifier } from '../platform/session.verifier';
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
import { SearchService } from './search.service';
interface SearchDto { query?: string; limit?: number }
/** Message search (session-auth). Results are permission-scoped inside the service to the caller's
* own scope + threads. Reindex backfills the caller's scope (idempotent). */
@Controller('v1/search')
export class SearchController {
constructor(
private readonly search: SearchService,
private readonly session: SessionVerifier,
private readonly actors: ActorResolver,
) {}
@Post()
async run(@Body() body: SearchDto, @Headers('authorization') authorization?: string) {
const principal = this.principal(authorization);
return this.search.search(principal, { query: body.query ?? '', ...(body.limit != null ? { limit: body.limit } : {}) });
}
@Post('reindex')
async reindex(@Headers('authorization') authorization?: string) {
const principal = this.principal(authorization);
const scope = await this.actors.findScope(principal);
return { indexed: await this.search.reindexAll(scope?.id) };
}
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,24 @@
import { Module } from '@nestjs/common';
import { OutboxModule } from '../outbox/outbox.module';
import { SearchService } from './search.service';
import { SearchProjector } from './search.projector';
import { SearchController } from './search.controller';
import { MESSAGE_SEARCH_PORT } from './message-search.port';
import { messageSearchFromEnv } from './meili.adapter';
/**
* Message search (Meilisearch). The engine binds from MEILI_URL; unset a no-op port so search
* returns empty rather than erroring. The projector indexes on `message.sent`; the service enforces
* the permission fence (scope + caller's threads). SessionVerifier + ActorResolver are global.
*/
@Module({
imports: [OutboxModule],
controllers: [SearchController],
providers: [
SearchService,
SearchProjector,
{ provide: MESSAGE_SEARCH_PORT, useFactory: () => messageSearchFromEnv() },
],
exports: [SearchService],
})
export class SearchModule {}
@@ -0,0 +1,35 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { CloudEvent, IIOS_EVENTS } from '@insignia/iios-contracts';
import { OutboxBus } from '../outbox/outbox.bus';
import { DlqService } from '../outbox/dlq.service';
import { ProjectionCursorService } from '../projection/projection-cursor.service';
import { SearchService } from './search.service';
/**
* Indexes messages into the search engine as they are sent. Reacts to the `message.sent` event on
* the outbox bus (same pattern as the inbox projector). Indexing is idempotent (upsert by id), so
* no per-event claim is needed a redelivered event just re-indexes the same doc.
*/
@Injectable()
export class SearchProjector implements OnModuleInit {
private readonly consumer = 'search-projector';
private readonly logger = new Logger(SearchProjector.name);
constructor(
private readonly search: SearchService,
private readonly bus: OutboxBus,
private readonly dlq: DlqService,
private readonly cursor: ProjectionCursorService,
) {}
onModuleInit(): void {
this.dlq.registerHandler(this.consumer, (e) => this.onMessageSent(e));
this.bus.on(IIOS_EVENTS.messageSent, (p) => void this.onMessageSent(p as CloudEvent).catch((err) => this.dlq.onConsumerFailure(this.consumer, p as CloudEvent, err)));
}
async onMessageSent(event: CloudEvent): Promise<void> {
const data = event.data as { interactionId?: string };
if (data.interactionId) await this.search.indexInteraction(data.interactionId);
await this.cursor.advance(this.consumer, event);
}
}
@@ -0,0 +1,57 @@
import { describe, it, expect, vi } from 'vitest';
import { SearchService } from './search.service';
import type { MessageSearchPort, SearchHit } from './message-search.port';
import type { ActorResolver, MessagePrincipal } from '../identity/actor.resolver';
import type { PrismaService } from '../prisma/prisma.service';
const principal: MessagePrincipal = { userId: 'u1', orgId: 'org', appId: 'app' };
function make(opts: { ready?: boolean; threadIds?: string[]; hits?: SearchHit[]; scope?: { id: string } | null } = {}) {
const engine: MessageSearchPort = {
ready: () => opts.ready ?? true,
index: vi.fn(async () => undefined),
remove: vi.fn(async () => undefined),
search: vi.fn(async () => opts.hits ?? []),
};
const actors = {
findScope: vi.fn(async () => (opts.scope === undefined ? { id: 'scope_1' } : opts.scope)),
resolveActor: vi.fn(async () => ({ id: 'actor_1' })),
} as unknown as ActorResolver;
const prisma = {
iiosThreadParticipant: { findMany: vi.fn(async () => (opts.threadIds ?? ['t1', 't2']).map((threadId) => ({ threadId }))) },
} as unknown as PrismaService;
return { svc: new SearchService(engine, prisma, actors), engine };
}
describe('SearchService (permission fence)', () => {
it('searches only within the caller scope + their thread ids', async () => {
const { svc, engine } = make({ threadIds: ['t1', 't2', 't3'], hits: [{ id: 'i1', threadId: 't2', text: 'hello world', subject: 'General', source: 'crm-messenger', at: 1, snippet: '<em>hello</em>' }] });
const res = await svc.search(principal, { query: 'hello' });
expect(res[0]!.id).toBe('i1');
expect(engine.search).toHaveBeenCalledWith('scope_1', ['t1', 't2', 't3'], 'hello', 20);
});
it('returns empty (no engine hit) for a blank query', async () => {
const { svc, engine } = make();
expect(await svc.search(principal, { query: ' ' })).toEqual([]);
expect(engine.search).not.toHaveBeenCalled();
});
it('returns empty when the caller belongs to no threads', async () => {
const { svc, engine } = make({ threadIds: [] });
expect(await svc.search(principal, { query: 'hello' })).toEqual([]);
expect(engine.search).not.toHaveBeenCalled();
});
it('returns empty when search is not configured (no-op engine)', async () => {
const { svc, engine } = make({ ready: false });
expect(await svc.search(principal, { query: 'hello' })).toEqual([]);
expect(engine.search).not.toHaveBeenCalled();
});
it('caps the limit at 50', async () => {
const { svc, engine } = make();
await svc.search(principal, { query: 'x', limit: 999 });
expect((engine.search as ReturnType<typeof vi.fn>).mock.calls[0]![3]).toBe(50);
});
});
@@ -0,0 +1,83 @@
import { Inject, Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
import { MESSAGE_SEARCH_PORT, type MessageSearchPort, type SearchDoc, type SearchHit } from './message-search.port';
/**
* Message search. The permission fence lives HERE: a query only ever runs against the caller's own
* scope AND the set of threads the caller currently belongs to (resolved live from participant
* rows, so membership changes are reflected immediately). The engine never sees a thread the caller
* isn't in. Text is drawn from the message's TEXT part; every conversation kind (chat, mail, sms)
* is a message with parts, so all are searchable through one index.
*/
@Injectable()
export class SearchService {
constructor(
@Inject(MESSAGE_SEARCH_PORT) private readonly engine: MessageSearchPort,
private readonly prisma: PrismaService,
private readonly actors: ActorResolver,
) {}
async search(principal: MessagePrincipal, opts: { query: string; limit?: number }): Promise<SearchHit[]> {
const q = (opts.query ?? '').trim();
if (!q || !this.engine.ready()) return [];
const scope = await this.actors.findScope(principal);
if (!scope) return [];
const actor = await this.actors.resolveActor(scope.id, principal);
const memberships = await this.prisma.iiosThreadParticipant.findMany({ where: { actorId: actor.id }, select: { threadId: true } });
const threadIds = memberships.map((m) => m.threadId);
if (threadIds.length === 0) return [];
return this.engine.search(scope.id, threadIds, q, Math.min(opts.limit ?? 20, 50));
}
/** Build + index the search doc for one interaction (called by the projector on message.sent). */
async indexInteraction(interactionId: string): Promise<void> {
if (!this.engine.ready()) return;
const doc = await this.buildDoc(interactionId);
if (doc) await this.engine.index([doc]);
}
/** Remove an interaction from the index (retention / redaction hook). */
async removeInteraction(interactionId: string): Promise<void> {
if (this.engine.ready()) await this.engine.remove([interactionId]);
}
/** Backfill: (re)index every text message, optionally for one scope. Returns the count indexed. */
async reindexAll(scopeId?: string, batch = 500): Promise<number> {
if (!this.engine.ready()) return 0;
const interactions = await this.prisma.iiosInteraction.findMany({
where: { ...(scopeId ? { scopeId } : {}), threadId: { not: null }, parts: { some: { kind: 'TEXT' } } },
select: { id: true },
});
let indexed = 0;
for (let i = 0; i < interactions.length; i += batch) {
const docs = (await Promise.all(interactions.slice(i, i + batch).map((x) => this.buildDoc(x.id)))).filter((d): d is SearchDoc => d !== null);
if (docs.length > 0) {
await this.engine.index(docs);
indexed += docs.length;
}
}
return indexed;
}
private async buildDoc(interactionId: string): Promise<SearchDoc | null> {
const i = await this.prisma.iiosInteraction.findUnique({
where: { id: interactionId },
include: { parts: { where: { kind: 'TEXT' }, take: 1 }, thread: true },
});
if (!i || !i.threadId || !i.thread) return null;
const text = i.parts[0]?.bodyText?.trim();
if (!text) return null; // nothing searchable
const meta = (i.thread.metadata as { source?: string } | null) ?? {};
return {
id: i.id,
scopeId: i.scopeId,
threadId: i.threadId,
text,
subject: i.thread.subject ?? null,
source: meta.source ?? null,
actorId: i.actorId ?? null,
at: i.occurredAt.getTime(),
};
}
}
@@ -13,7 +13,7 @@ export async function resetDb(prisma: PrismaClient): Promise<void> {
"IiosCalendarSyncCursor","IiosCalendarEvent","IiosCalendarProviderAccount","IiosAvailabilityWindow",
"IiosAiEvidenceLink","IiosAiClaim","IiosAiToolCall","IiosAiArtifact","IiosAiModelRun","IiosAiJob","IiosEmbeddingRef",
"IiosRouteDecision","IiosRouteBinding","IiosModerationFlag",
"IiosInboundRawEvent","IiosDeliveryAttempt","IiosOutboundCommand","IiosRateLimitBucket",
"IiosInboundRawEvent","IiosDeliveryAttempt","IiosOutboundCommand","IiosRateLimitBucket","IiosMediaObject",
"IiosTicketStateHistory","IiosTicketThreadLink","IiosCallbackRequest","IiosTicket",
"IiosSupportTeamMember","IiosSupportQueue",
"IiosInboxItemStateHistory","IiosInboxItem","IiosUnreadCounter","IiosMessageReceipt",
@@ -70,6 +70,17 @@ export class ThreadsController {
return this.messages.addParticipant(id, this.principal(auth), body.userId, body.role);
}
/**
* Governed bulk membership: add many users in one call (e.g. importing another channel's roster).
* Reports per-user outcome `skipped` are already members so a partial result is never silent.
*/
@Post(':id/participants/bulk')
@HttpCode(200)
async addParticipants(@Param('id') id: string, @Body() body: { userIds?: string[]; role?: string }, @Headers('authorization') auth?: string) {
if (!Array.isArray(body?.userIds)) throw new BadRequestException('userIds must be an array');
return this.messages.addParticipants(id, this.principal(auth), body.userIds, body.role);
}
/** Members of a thread with their role — drives the group settings member list. */
@Get(':id/participants')
async listParticipants(@Param('id') id: string, @Headers('authorization') auth?: string) {
+8
View File
@@ -415,6 +415,9 @@ importers:
jwks-rsa:
specifier: ^4.1.0
version: 4.1.0
meilisearch:
specifier: ^0.45.0
version: 0.45.0
nodemailer:
specifier: ^9.0.3
version: 9.0.3
@@ -2715,6 +2718,9 @@ packages:
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
engines: {node: '>= 0.8'}
meilisearch@0.45.0:
resolution: {integrity: sha512-+zCzEqE+CumY4icB0Vox180adZqaNtnr60hJWGiEdmol5eWmksfY8rYsTcz87styXC2ZOg+2yF56gdH6oyIBTA==}
memfs@3.5.3:
resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==}
engines: {node: '>= 4.0.0'}
@@ -5902,6 +5908,8 @@ snapshots:
media-typer@1.1.0: {}
meilisearch@0.45.0: {}
memfs@3.5.3:
dependencies:
fs-monkey: 1.1.0