- 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>
The new-conversation + channel-create forms are flex columns, so the submit
button stretched to full width and read as thin. Give it align-self:flex-start
+ a 38px height so it sizes to its label like a normal button.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a 'New message' + on the Direct messages section that opens a people picker
(NewConversation): pick one person -> DM, two or more -> group with an optional
name, via the adapter's existing openThread({participantIds, membership, subject}).
Expose directory() on MessagingAdapter (org people list); MockAdapter implements
it + dedupes 1:1 DMs. 72 tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A host wrapper like .view{position:relative;z-index:1} creates a stacking context
that traps the modal's fixed overlay below a sibling sticky header, no matter its
z-index. Render the modal through a ModalPortal (createPortal to document.body) so
it escapes the host stacking context + overflow entirely; the portal root copies
the SDK theme tokens from the live surface (synchronously, no unstyled paint) and
carries dark defaults as a fallback. Adds react-dom peer dep. 67 tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the hand-authored migration with one produced by `prisma migrate diff`
(offline, engine-generated) and verified by replaying the full 24-migration chain
into a throwaway Postgres DB. Same DDL, but generated through Prisma tooling so the
migration history stays consistent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
IIOS becomes the tenant's integration/credential hub for anything it sends.
New IiosProviderCredential (one row per scope+providerType) seals the secret
with AES-256-GCM under IIOS_CRED_KEY (secret-crypto.ts); only non-secret
displayHints are ever read back. ProviderCredentialService upsert/resolve/status;
TwilioSmsProvider resolves the caller's own creds per scope at send time and
POSTs to Twilio (fail-closed NOT_CONFIGURED when unset). Registered over the
SMS sandbox only when the platform key + store are present. PUT/GET
/v1/providers/:type/credentials (scope-fenced, masked reads). 16 new unit tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
InboxAdapter gains uploadAttachment + attachment params on mailReply/
composeInternal/composeExternal. MailReader reply and ComposeModal grow an
attach affordance (paperclip + pending chips); mail history renders sent
attachments. MockInboxAdapter implements upload. 67 tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The emoji picker used bottom:100% and was clipped by the message list's
overflow when reacting to a message near the top (and could spill past the
right edge). Open it downward + edge-anchored (right for others' messages,
left for my own) with width:max-content so all emojis stay visible.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replies (messages with parentInteractionId) now open in a right-hand ThreadPane
instead of inline quoting:
- extracted a shared Composer (draft + @mention autocomplete + attach) and
MessageItem (bubble + mentions + attachment + reactions + reply affordance)
- Thread shows top-level messages only; a message with replies gets a
'💬 N replies' link, and hovering shows 💬 'Reply in thread'
- ThreadPane renders the root + its replies + a composer that posts back with
parentInteractionId; Messenger becomes 3-column (list | thread | pane),
pane closes on conversation switch
- no contract/adapter/backend change (parentInteractionId was already wired)
- thread-pane render test (open → reply → parent shows the count); 58 green
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- useMessages exposes upload(); Thread gains a hover reaction picker (react to
a message) and an attach button (upload → stage → send), plus inline image /
file rendering of message attachments — all capability-degrading (hidden when
the adapter lacks react/upload)
- themeable styles for the picker, attach button, staged chip, and attachments
- 57 tests still green
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- iios-kernel-client: RestClient.discoverThreads (GET /v1/threads/discover) +
leaveThread (DELETE /v1/threads/:id/me); DiscoveredThread type
- KernelClientAdapter implements the four channel methods: browseChannels maps
discovered public channels → ChannelSummary; createChannel → createThread
(membership=channel, ADMIN, visibility/topic metadata); joinChannel →
socket.openThread (governed public self-join); leaveChannel → rest.leaveThread
- adapter channel test over the fake transport (13 kernel-adapter tests); 57 green
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The generic primitives channels need beyond dm/group, kept policy-governed and
scope-fenced (kernel never interprets 'channel'/'public'):
- discoverThreads(principal, filter): scope-wide browse (NOT membership-scoped)
matching an opaque metadata filter, each result flagged joined; governed by
iios.thread.discover (scope fence in the query). REST: GET /v1/threads/discover
- open self-join for PUBLIC channels: openThread now passes visibility into the
join decision; dev-OPA iios.thread.join allows membership=channel+visibility=
public (dm/group/private-channel stay invite-only)
- leaveThread + iios.thread.leave + REST DELETE /v1/threads/:id/me
- tests: public self-join vs private denied, discover joined-flags + group
excluded + leave; dev-opa join-public/discover/leave rules (29 green)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a third membership beyond dm/group: a discoverable, joinable room.
- contract: Membership += 'channel'; Conversation.topic; ChannelSummary +
CreateChannelInput; optional adapter methods browseChannels/createChannel/
joinChannel/leaveChannel (capability-degrading — absent hides the UI)
- MockAdapter implements them (seeds a joined public, a joinable public, and a
private channel); listConversations now returns only threads I'm in
- useChannels hook (browse/create/join/leave + supported flag)
- UI: sidebar sections (Channels vs Direct messages), a + that opens a
ChannelBrowser (join + create), # / lock glyphs; themeable styles
- KernelClientAdapter passes channel membership + topic through
- 4 channel tests (mock browse/join/create + render browse→join); 52 total green
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Turns the SDK from hooks-only into a real UI SDK: <Messenger> (conversation
list + open thread + composer), plus <ConversationList> and <Thread>, all
driven by the existing useConversations/useMessages hooks over the injected
adapter — zero transport imports (boundary intact).
- themeable via --miu-* CSS variables; default theme ships as ./styles.css
- optimistic send, typing indicator, seen ticks, reactions display, unread badges
- render smoke tests (jsdom + MockAdapter): mounts, auto-selects first thread,
sends a message, switches threads (3 tests) — 48 total green
- tsup: css bundled to dist/styles.css; dts scoped to TS entries
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements MessagingAdapter over @insignia/iios-kernel-client (browser → IIOS,
token-in): listConversations/openThread/history/send/subscribe/typing/markRead
/react, mapping kernel Message/ThreadSummary/annotations onto the SDK's neutral
types. currentActorId comes from the host's session (senderId space), never
inferred from history — the exact bug the conformance suite kills.
- lives in adapters/ (transport boundary intact — core stays transport-free)
- ships from the ./adapters/kernel-client subpath (kernel-client is an optional
peer dep; excluded from the main bundle)
- connectKernelAdapter({serviceUrl, token, currentUserId}) convenience
- passes the shared adapter conformance suite (12 tests) over the real
MessageSocket facade with only the socket.io layer faked
- media/attachments deferred (kernel-client has no presign yet)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- media upload policy now allows text/html, text/markdown, text/x-markdown,
text/csv (in addition to images/av, pdf, txt, zip, office docs)
- blob endpoint adds X-Content-Type-Options: nosniff, and forces
Content-Disposition: attachment for script-capable types (html, xhtml, svg,
xml) so an uploaded file can't render/execute inline from the IIOS origin
(stored-XSS). Images/video/audio/pdf still serve inline for preview.
- dev-opa test covering the allowed types + unknown/oversize denials
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- MailInternalDto accepts attachments[]; deposit() stores each as a media
part (image/video→MEDIA_REF, audio→VOICE_REF, else FILE_REF)
- ingest now persists part sizeBytes; contract + DTO carry it
- threads.getMessages returns mimeType + sizeBytes so mail readers can
render inline images / file chips
- test: internal mail stores an image attachment as a MEDIA_REF part
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ingest() puts metadata on the interaction, not the thread, and listThreads filters
THREAD metadata — so MailService now tags the thread directly after deposit
(source=crm-mail). Lets the CRM list mail threads apart from Messenger chat
(source=crm-messenger). 7 tests. NOTE: requires an IIOS redeploy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drop-in S3-compatible backend for object storage (media + email attachments) —
the swap the StoragePort seam was designed for. MinIO/self-hosted/R2/Supabase all
work via endpoint + path-style.
- S3Storage implements StoragePort (put/get/remove); sha256 computed locally on put
(matches LocalDiskStorage); a missing object reads back as null, not an error.
- Env-driven binding in MediaModule: IIOS_S3_BUCKET + keys set → S3Storage, else
LocalDiskStorage. forcePathStyle defaults true (MinIO); endpoint omitted → AWS.
- S3 client is injectable so tests run with no live server.
6 unit tests (config parse, put size/sha, get round-trip, NoSuchKey→null, remove).
Full suite 300/300, boundary + build clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Email can now carry attachments (e.g. an invoice PDF). The kernel already stored
attachments as message parts + the media StoragePort holds the bytes; the gap was
the email envelope.
- EMAIL payload carries attachment REFS ({filename, contentRef, mimeType}), not
bytes — the ledger + T8 PII redaction stay small; bytes are fetched at send time.
- SmtpProvider takes an AttachmentResolver; resolves each ref via storage and attaches
(nodemailer). FAILS CLOSED if a declared attachment can't be resolved (or no resolver
is wired) — never send a receipt/invoice missing its file; a FAILED command retries.
- CapabilityProviderRegistry injects the resolver from STORAGE_PORT (@Optional);
MediaModule exports STORAGE_PORT, CapabilityModule imports MediaModule. No cycle.
- TemplatedSender + MailService + the /v1/mail/send DTO pass attachments through.
Verified: 6 new unit tests (attach, fail-closed x2, plain-unaffected, resolver,
pass-through) + a REAL Ethereal SMTP send WITH a PDF attachment (SENT). Full suite
294/294, boundary + build clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MailService renders a template then deposits it as an EMAIL interaction on a
per-email thread (thread model: one thread per email), reusing IngestService.
Because ingest adds no participants and a thread is only visible to its
participants, it ensureParticipant()s BOTH sender and recipient — so the mirror
is actually visible.
- postInternal: app-to-app mail, no SMTP → recipient's in-app inbox.
- sendExternalWithMirror: SMTP send (TemplatedSender) + mirror an interaction
ONLY for a registered recipient (pre-registration sends are email-only — no
inbox exists yet). Idempotent across both the send and the mirror.
- Writes Interactions, NEVER InboxItems (the projector owns those — KG-15).
- POST /v1/mail/internal, POST /v1/mail/send. MailModule in AppModule.
Verified over HTTP: internal mail → recipient SEES the thread in their inbox;
external send → command + mirror; no-source/no-auth 400. 7 unit tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Makes external email actually leave the building (was sandbox-only). SmtpProvider
implements CapabilityProvider; env-activated (accounts@ primary, ceo@ fallback);
transporter injected for tests.
- send() maps target+payload -> {from,to,subject,html,text,inReplyTo,references};
providerRef = nodemailer's real Message-ID (so replies thread via In-Reply-To).
- Fallback ONLY on pre-acceptance failures (connect/auth/timeout) — a post-acceptance
error is terminal, so a message the server already took can't be double-delivered.
- Never throws — transport failure -> FAILED, per the adapter doctrine.
- Registry precedence via registration order: SMTP > HTTP relay > sandbox for EMAIL.
Verified: 13 unit tests (config/envelope/fallback/precedence) + a REAL SMTP round-trip
against nodemailer Ethereal (SENT, genuine Message-ID). Full suite 281/281, boundary+build clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An email/SMS command holds PII: the recipient address (target) and the rendered
body (payload). RetentionService now snapshots outbound commands and, once aged,
redacts target->'[redacted]' and payload->{redacted:true} in place while KEEPING
the template provenance (key/version/locale/hash) — so 'which template version did
we send?' stays answerable after the PII is gone.
- ensureOutboundSnapshots: one snapshot per command, dataClass 'outbound',
archiveAfter==deleteAfter (PII goes straight to redact, no archive phase).
Nullable command scope uses an 'unscoped' tag so the global sweep still reaches it.
- applySweep redact branch switches on targetType; compliance holds honored; audit
'retention.redacted' resourceType 'outbound_command'.
Closes lever #2 of the PII-minimization plan. 3 new + 6 regression tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
POST /v1/templates/send (render→queue) and /preview (render only). OPA-gated in
the sender: iios.template.send, and iios.template.send.inline for ad-hoc HTML
(arbitrary markup to a customer is riskier than a reviewed stored template).
Scope resolved from the caller's principal. TemplateModule registered in AppModule.
Verified over HTTP against a local boot: preview + send of the seeded
payment.receipt (SENT via sandbox, provenance persisted), XSS name escaped,
idempotent replay → 1 row, unknown key 404, bad channel/no-source/no-auth 400.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
TemplateSeeder (OnModuleInit) seeds repo file defaults as global (scopeId NULL)
templates if absent. Idempotent per (key,channel,locale,version): re-boot inserts
nothing; a bumped version adds a new row and keeps the old for audit. Seeds:
welcome (email), payment.receipt (email+sms), onboarding.reminder (email) —
placeholder copy for marketing to replace. 3 tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- IiosOutboundCommand gains templateKey/version/locale/renderedHash (migration).
- OutboundService.send accepts an optional opaque `provenance` and writes it into
the command on BOTH the PENDING and RATE_LIMITED create paths — the only way to
stamp provenance by construction, since send() creates the row itself (review
finding). OutboundService still neither renders nor resolves templates.
- TemplatedSender.sendTemplated: render -> OutboundService.send -> provenance, one
entry point. EMAIL payload = {subject,html,text}; SMS = {text}. Idempotent per key.
Tests: 4 sender + 16 adapters regression green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Schema + migration for the template source table, and TemplateRepository.resolve:
highest active version for (key,channel,locale), scoped override preferred over
the global (scopeId NULL) default.
Migration adds a PARTIAL unique index WHERE scopeId IS NULL so two platform
defaults for the same key can't coexist (Postgres treats NULL as distinct under
a plain UNIQUE — the scoped constraint alone wouldn't catch it). 6 tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
renderTemplate(raw, vars) → {subject, html, text}. HTML body auto-escaped
(customer names into email = XSS risk); subject/text verbatim. Throws on a
missing declared variable — never send a half-rendered receipt. 5 tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The delegated socket token (aud=IIOS_REALTIME_AUDIENCE) was only narrow in one direction: the
gateway accepts ONLY that audience, but REST never checked the actor token's audience — so a
leaked browser socket token still drove privileged REST, i.e. a full actor token with extra steps.
RealtimeTokenRestGuard closes it, registered GLOBALLY (APP_GUARD) because every REST route is a
target — only 2 of 15 controllers sit behind ContextAttestationGuard, so inbox/media/interactions/
ai/... would otherwise stay open.
It is a decode-only REJECT filter, never an authenticator:
- does not verify signatures (controllers still call SessionVerifier); stripping `aud` to bypass it
invalidates the signature downstream,
- no bearer -> pass through (health, metrics, HMAC adapter webhooks),
- ws context -> pass through (the gateway enforces the mirror rule),
- unset IIOS_REALTIME_AUDIENCE -> no-op, the same switch that turns on the socket half.
So one env now enables the whole boundary: socket accepts only iios-message, REST refuses it.
8 tests; typecheck + build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The socket used to accept any valid token, so a full REST/actor token could open the
live stream. Now it can require a narrowly-scoped delegated token (aud=iios-message),
so a leaked socket token can't drive privileged REST, and vice-versa.
- MessagePrincipal gains `audience`, surfaced from both verify paths (OIDC aud, and the
app-token `aud` claim).
- message.gateway: when IIOS_REALTIME_AUDIENCE is set, handleConnection accepts only a
token whose aud matches (opt-in, like IIOS_REQUIRE_ATTESTATION; unset = no change).
- spec: verifier surfaces aud; gateway accepts iios-message, rejects iios-core / no-aud
when enforcing, passes through when off.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Proof #3 now supports the production asymmetric path, not just the dev shared secret.
- context-attestation.ts: PublicKeyResolverPort seam; verify() picks ES256 (JWKS by
kid) when the client has a jwksUri, else HS256 (dev). signDevAttestationES256 helper.
- attestation-stores.ts: JwksPublicKeyResolver (jwks-rsa, one cached client per URI,
refetch on rotation, fail-closed to NO_KEY).
- attestation.module.ts: inject the resolver into the verifier; dev-seed the client as
clientType APPSHELL (it is the CRM browser-flow parent per the July-12 notes).
- rename the registered client crm-support-widget -> appshell-crm (the name reflects the
attesting parent, not a widget).
- specs: ES256 (valid via JWKS, wrong key -> BAD_SIGNATURE, unknown kid -> NO_KEY, no
resolver -> NO_KEY) + two stolen-token gate cases (wrong app binding -> APP_MISMATCH,
wrong audience -> WRONG_AUDIENCE).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
RestConfig.headers now accepts a function, invoked once per request, so a caller can
mint a FRESH context attestation (new single-use nonce) each call. A static header was
replay-rejected on the 2nd request of a multi-call operation. Published 0.1.4.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaced the per-thread findFirst inside Promise.all (N parallel queries) with a
single Postgres DISTINCT ON query. A caller with many threads no longer fans out
and exhausts the connection pool (the conversation.list 500 under accumulated data).
message.spec 16/16 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds optional RestConfig.headers, merged into every request, so a server-side
caller (be-crm's glue) can carry X-Context-Attestation (the July 12 proof #3)
alongside the bearer token. Backward compatible. Published 0.1.3.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Applies the context-attestation verifier at the request boundary via a guard, so a
stolen/forged/replayed attestation is rejected before IIOS acts — while staying safe
to roll out.
- ContextAttestationGuard: if an X-Context-Attestation header is present, verify it
(its app_id must match the actor token's app_id) → 403 on any failure; if absent,
allow only when IIOS_REQUIRE_ATTESTATION != 1 (dev/zero-trust), else 403. The flag is
read per-request and defaults OFF, so existing callers keep working until AppShell/
be-crm start forwarding attestations.
- AttestationModule (@Global): provides the verifier + Prisma stores + guard, and
dev-seeds the crm-support-widget client so locally minted attestations verify.
- Guard applied to ThreadsController + SupportController.
- Tests (5 pass, no DB): not-required-allows, required-rejects, valid, forged, replayed.
Workload/mTLS (proof #2) stays a mesh concern. Next: SDK header passthrough + demote
be-crm IiosClient to forward an attestation, then flip the flag to prove end-to-end.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A valid actor token is no longer enough. This adds the trust-layer core so IIOS
can require a signed CONTEXT ATTESTATION from an authorized parent (AppShell etc.)
before a privileged request — defeating a hacked app replaying a stolen token.
- prisma: IiosClientRegistry (registered clients allowed to attest, per app) +
IiosAttestationNonce (single-use replay ledger). Migration applied.
- ContextAttestationVerifier + ports (ClientRegistryPort, NonceStorePort) + a dev
signer. Verifies: registered+active client, signature, aud=iios, app_id matches
the token AND is allowed for the client, freshness, single-use nonce. Fail-closed.
- Prisma-backed stores; nonce reserve is atomic via the unique PK (P2002 = replay).
- Tests (9 pass): happy path + the five rejection cases the CEO named
(unknown client, wrong audience, expired, replayed nonce, app-mismatch) +
forged-signature + disabled-client; a DB atomicity test (skips if engine offline).
Decoupled from the request path on purpose — wiring it into the messaging guard
(the three-proof gate) + demoting be-crm to an attestation forwarder is the next step.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
NestJS 11 runs Express 5, whose default 'simple' query parser ignores nested
params like ?metadata[key]=value — so GET /v1/threads silently dropped the
metadata filter, causing e.g. an app's "reuse existing thread for this customer"
check to match ANY thread. Set the qs-based 'extended' parser.
Verified: filtering by a non-existent metadata value now returns 0 (was returning
all); the be-crm support smoke goes 12/12 (was 10/12 once a prior thread existed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>