Commit Graph

116 Commits

Author SHA1 Message Date
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 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 f16d7986c7 fix(migration): regenerate provider_credentials via prisma engine, not hand-written SQL
Replace the hand-authored migration with one produced by `prisma migrate diff`
(offline, engine-generated) and verified by replaying the full 24-migration chain
into a throwaway Postgres DB. Same DDL, but generated through Prisma tooling so the
migration history stays consistent.

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:21 +05:30
maaz519 b4104b9769 feat(media): allow HTML/Markdown/CSV uploads; serve scriptable types as downloads
- media upload policy now allows text/html, text/markdown, text/x-markdown,
  text/csv (in addition to images/av, pdf, txt, zip, office docs)
- blob endpoint adds X-Content-Type-Options: nosniff, and forces
  Content-Disposition: attachment for script-capable types (html, xhtml, svg,
  xml) so an uploaded file can't render/execute inline from the IIOS origin
  (stored-XSS). Images/video/audio/pdf still serve inline for preview.
- dev-opa test covering the allowed types + unknown/oversize denials

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 14:30:25 +05:30
maaz519 ce33834d56 feat(threads): governed group settings — rename, list members, remove participant
- MessageService.renameThread / removeParticipant / listParticipants,
  each fail-closed via OPA (kernel stays generic — no dm/group branching)
- dev OPA: iios.thread.update + iios.thread.participant.remove rules
  (group requires ADMIN; ungoverned threads unchanged)
- REST: PATCH /v1/threads/:id, GET + DELETE /v1/threads/:id/participants
- tests: admin renames/removes, member is denied, member list carries roles
  (message.spec + dev-opa.port.spec)

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:24:49 +05:30
maaz519 40c98522dc feat(mail): tag mail threads source=crm-mail (list separately from chat)
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>
2026-07-18 16:30:28 +05:30
maaz519 0c6650f3c6 feat(media): S3/MinIO storage adapter (StoragePort)
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>
2026-07-18 14:26:53 +05:30
maaz519 d28c873d40 feat(mail): email attachments over SMTP
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>
2026-07-18 14:14:20 +05:30
maaz519 9b075f46f9 feat(mail): inbox mirror + INTERNAL (app-to-app) delivery
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>
2026-07-18 13:54:32 +05:30
maaz519 74cca2d534 feat(smtp): SMTP egress provider (nodemailer) for the EMAIL channel
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>
2026-07-18 13:31:04 +05:30
maaz519 65023ce404 feat(templates): T8 PII redaction of outbound commands (fast-follow)
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>
2026-07-18 12:56:22 +05:30
maaz519 b44f795ba4 feat(templates): T6 controller + DTOs + module wiring
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>
2026-07-18 12:39:04 +05:30
maaz519 37407cb0af feat(templates): T5 boot seeder + default seeds
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>
2026-07-18 12:33:12 +05:30
maaz519 10f3545a25 feat(templates): T4 sendTemplated() + outbound provenance
- 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>
2026-07-18 12:30:19 +05:30
maaz519 8ce647552a feat(templates): T3 render() service (resolve+render, inline, version pin)
TemplateService.render(source, vars, opts) → { content, provenance }. Stored
key → resolve+render; inline → render without a DB row (key/version null);
explicit version pins. Provenance = {templateKey, version, locale, sha256(content)}.
10 tests (svc + repo).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 12:25:27 +05:30
maaz519 b3afd44d00 feat(templates): T2 IiosMessageTemplate table + resolve()
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>
2026-07-18 12:23:44 +05:30
maaz519 8768af96c1 feat(templates): T1 pure Handlebars renderer
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>
2026-07-18 12:21:09 +05:30
maaz519 6dc9e4ffee feat: reject socket-scoped tokens on REST (the other half of realtime delegation)
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>
2026-07-15 20:29:59 +05:30
maaz519 3298401772 feat: enforce realtime delegation audience on the message socket
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>
2026-07-15 12:56:19 +05:30
maaz519 e39caa3c80 feat: verify context attestations via ES256/JWKS + rename client to appshell-crm
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>
2026-07-15 12:56:10 +05:30
maaz519 3e66c7a5db perf(iios): listThreads fetches last-message-per-thread in one query
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>
2026-07-13 21:28:37 +05:30
maaz519 f2d590b04d feat(iios): wire the three-proof gate into the request path (§1b)
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>
2026-07-13 20:49:40 +05:30
maaz519 dd6a4cd4fa feat(iios): context-attestation verifier — the July 12 stolen-token proof (§1)
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>
2026-07-13 20:11:04 +05:30
maaz519 77dd5bac82 fix(iios): use extended query parser so nested metadata filters parse
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>
2026-07-11 14:33:28 +05:30
maaz519 b918a21083 feat(iios): generic opaque metadata + manual ticket assign (glue prereqs)
Generic kernel primitives so app backends (e.g. a CRM support glue) can link
their domain to interactions WITHOUT the kernel learning any app vocabulary:

- thread: accept an opaque `metadata` bag on create (merged with membership),
  echo it in listThreads, and filter listThreads by `?metadata[key]=value`
  (equality on the opaque bag — the kernel never interprets the keys).
- support: accept opaque `metadata` on createTicket/escalate (thread bag
  inherited); add SupportService.assignTo — a policy-gated manual assignment
  of a ticket to a target actor (POST /v1/support/tickets/:id/assignee).
- SDK @insignia/iios-kernel-client 0.1.2: createThread(metadata),
  listThreads({metadata}) filter, createTicket(metadata), escalate(metadata),
  assignTicket; ThreadSummary/Ticket expose the opaque bag.

Generic-safety gate holds: no crm/customer/lead in kernel code (opaque values
only). Tests: +2 (metadata create/filter, ticket metadata + assignTo); 31 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 18:39:30 +05:30
maaz519 0a8544b6fc feat(iios): notification engine — presence-gated Web Push (engine phase)
Deploy iios-service / build-deploy (push) Failing after 4m37s
CI / build (push) Successful in 5m25s
Generic notification engine: reacts to message.sent and runs three gates before
delivering — policy (DM always; group only on @mention or reply-to-you), presence
(skip if the recipient is focused on that thread), mute (per-thread). Delivery via a
swappable NotificationPort (Web Push/VAPID adapter); a 'gone' (404/410) prunes the sub.

- PresenceService + gateway `focus_thread` signal + disconnect cleanup (room membership
  != viewing, since the sidebar joins every thread room).
- IiosNotificationSubscription table + `muted` on IiosThreadParticipant (migration).
- Endpoints: GET vapid-public-key, POST/DELETE subscribe, POST threads/:id/mute|unmute;
  listThreads returns the caller's `muted`.
- DM-vs-group read from the opaque `membership` attribute in the notification POLICY only
  — kernel stays generic (grep-verified).

Tests: 13 new (3 gates + reply-to-you + prune + web-push adapter states); full suite 205
green. Verified live: module boots, subscribe stored, mute/unmute round-trips.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 19:16:53 +05:30
maaz519 24a87f6fb6 feat(iios): media sharing — presigned storage port + attachments on messages
Deploy iios-service / build-deploy (push) Failing after 3m35s
CI / build (push) Successful in 3m47s
Media the industry way: the DB stores a reference, bytes live behind a storage port.
- StoragePort + LocalDiskStorage (dev). MediaService presigns short-lived signed
  upload/download URLs (HS256 tokens) pointing at IIOS's own endpoints; the bytes
  never touch the kernel. Prod swaps STORAGE_PORT to S3/Supabase — same as auth.
- MediaController: presign-upload / PUT upload/:token (raw stream) / GET blob/:token /
  presign-download. Uploads are OPA-governed (iios.media.upload: 25 MB cap +
  image/video/audio/pdf/office allowlist); downloads are tenant-fenced by object key.
- send() + MessageDto carry an attachment (contentRef/mimeType/sizeBytes → generic
  MEDIA_REF/VOICE_REF/FILE_REF part; DTO exposes kind image|video|audio|file).

Tests: media.service.spec (6) — round-trip, oversize/type denied, oversized PUT
refused, tampered token rejected, tenant fence. Full suite 192 green. Verified live:
presign→upload→send→history→signed download round-trips the exact bytes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 01:15:03 +05:30
maaz519 1256664361 feat(iios): expose senderId (stable externalId) on MessageDto
CI / build (push) Successful in 4m53s
Deploy iios-service / build-deploy (push) Failing after 8m37s
The app needs a reliable "is this message mine?" signal. senderActorId worked only
with a lazily-learned actorId, which Supabase token-refresh wipes. senderId is the
sender's externalId (email/username) — always present and comparable to the session
user — so message alignment is deterministic. Verified: senderId round-trips = email.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 20:58:10 +05:30
maaz519 0ecc8a4ada feat(iios): multi-issuer session verifier (many IdPs/apps → isolated appId scopes)
SessionVerifier now holds a REGISTRY of trusted OIDC issuers instead of a single
Supabase project. A token is routed by its `iss` claim to that issuer's entry,
verified against that issuer's JWKS (ES256, no secret), and stamped with that
entry's appId/orgId — so two Supabase projects / IdPs map to two isolated app
scopes on one IIOS (chat vs a future support app). App A's tokens can't reach B.

- Config: `AUTH_ISSUERS` (JSON array of { url, appId, orgId? }); the single
  `SUPABASE_URL` (+ SUPABASE_APP_ID) still works as a one-entry shorthand.
- Per-issuer JWKS cache; untrusted issuer → reject; forgery (right issuer claim,
  wrong key) → reject.
- HS256 app-token path (dev/tests) unchanged.

Tests: new session.verifier.spec (4) — routes to correct appId, second issuer →
different appId, untrusted issuer rejected, cross-issuer forgery rejected.

Note: outbox `replay.spec` has a pre-existing clock-skew flake (nextAttemptAt vs
Postgres now()) that surfaces under certain suite orderings — unrelated to this
change (fails in isolation on a clean tree; passes when another spec runs first).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 20:38:30 +05:30
maaz519 e956ad3cb9 feat(iios): verify real Supabase access tokens (ES256 via JWKS)
SessionVerifier gains a Supabase mode (set SUPABASE_URL): user SATs are ES256,
verified against the project's public JWKS — no shared secret. Keys are fetched at
startup (onModuleInit) and cached as PEM so verify() stays synchronous; a rotated
kid triggers a background refresh. Claims map to MessagePrincipal with userId=email
(stable, human-readable → mentions/directory keep working; RealMDM canonicalises
later). The legacy HS256 app-token path is unchanged (dev/tests untouched).

senderName now prefers the actor display name over the raw handle, so real names
show on bubbles when identity is an email.

Verified end-to-end against a live project: signup → real ES256 SAT → GET /v1/threads
200 → thread created + owned by the resolved email principal. Full suite 182 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 20:14:28 +05:30
maaz519 8c814d9b86 feat(iios): @mentions via inbox fan-out + my-annotations query (pins/saves)
Mentions ride the existing event-driven inbox — no chat parsing in the kernel:
- send() carries an OPAQUE mentions[] (userIds) into the message event; the kernel
  never parses "@". The app supplies the notify-list.
- InboxProjector fans out a MENTION inbox item (new generic inbox kind) to each
  mentioned *participant* (never the sender), idempotent per source message; reading
  the thread resolves the reader's MENTION + NEEDS_REPLY items to DONE.
- New MENTION value in the generic IiosInboxItemKind taxonomy (migration).

Pins/saves reuse the annotation primitive; new generic query powers a Saved list:
- MessageService.listMyAnnotated(principal, type) + GET /v1/threads/my-annotations
  returns the caller's annotated messages (type "save") with thread context.

Tests: 182 pass (+4: mention fan-out, resolve-on-read, saved query, opaque mentions).
Verified live: mention→inbox delivery, read→resolve, pin/save persistence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 01:35:30 +05:30
maaz519 f3c4ba72b5 feat(iios): generic interaction-annotation primitive (backs emoji reactions)
Adds IiosInteractionAnnotation — an actor attaches an OPAQUE (annotationType, value)
label to an interaction. The kernel stores + aggregates them but never interprets the
strings (the chat app writes type "reaction" / value = emoji); the same primitive backs
pins/saves/flags/tags later. No chat vocabulary in kernel code — verified by grep.

- MessageService.toggleAnnotation(): governed (participant-only via new OPA rule
  iios.interaction.annotate), idempotent toggle keyed on
  (scope, interaction, actor, type, value); history DTO carries aggregated
  { type, value, users[] } groups.
- Gateway: `annotate` event → broadcasts `annotation` (refreshed user list) to the room.
- DevOpaPort: annotate allowed only for thread members (real OPA can tighten later).
- Migration add_interaction_annotations (additive; dev data untouched).

Tests: 178 pass (+3: toggle/aggregate, coexisting values, non-member denied).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 00:20:47 +05:30
maaz519 c4206f9809 fix(iios): thread subject on create, graceful open_thread error, isolated test DB
- openThread/createThread accept a generic `subject` (group name) — stored on the
  thread, echoed via listThreads; kernel never interprets it.
- Gateway open_thread now returns an ACK'd { error } on failure instead of throwing
  (which never acked → clients hung on "loading"). Non-member/missing-thread opens
  fail cleanly.
- Tests run against an isolated `iios_test` database (vitest globalSetup creates +
  migrates it; test.env overrides DATABASE_URL) so `pnpm test` can never TRUNCATE the
  dev database again. Verified: full suite green, dev DB row counts unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 19:39:19 +05:30
maaz519 1af10d0f4a feat(iios): message senderName + dev user directory
- Messages now carry senderName (resolved actorId → sourceHandle.externalId) so
  clients render usernames instead of raw actor cuids.
- GET /v1/dev/users exposes the dev directory (DEV_USERS keys) so a host app can
  validate add-by-username; real apps validate against their user store / MDM.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 17:11:20 +05:30
maaz519 31f7682a46 feat(iios): include participant names in listThreads (generic)
GET /v1/threads now returns participants[] (member usernames) so a host app can
render conversation titles / member lists without a second call. Still generic —
it only lists members of threads the caller belongs to.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 16:27:47 +05:30
maaz519 ba745bb71a feat(iios): policy-enforced membership + threaded replies + dev IdP (generic)
Enriches the platform PLANE, not the kernel:
- DevOpaPort: the dev OPA stub now evaluates a small policy table (behind the
  same opa.decide) — DM capped at 2, add-participant requires member/group-admin,
  governed self-join for membership threads. Real OPA swaps in unchanged.
- Dev IdP login (POST /v1/dev/login) issues the same JWT claims a real IdP would.
- PolicyDeniedFilter maps fail-closed denials to HTTP 403.

Generic kernel additions (no chat vocabulary — 'dm'/'group' live only as OPA
policy + an opaque thread attribute):
- MessageService.addParticipant (governed membership by userId), governed
  openThread self-join (scoped to threads with a membership attribute),
  parentInteractionId on send (reply link), and a generic listThreads.
- REST: GET /v1/threads, POST /v1/threads, POST /v1/threads/:id/participants;
  socket add_participant + membership/parentInteractionId. ensureParticipant
  gains a role.

Tests: dev-opa.port.spec + message.spec (DM cap / group admin / governed join /
listThreads / reply). smoke-membership.mjs; realtime smokes updated for governed
join. 175 unit tests + all smokes green; kernel free of dm/group literals.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 16:27:47 +05:30
maaz519 f0ea1ca553 feat(iios): email adapter dedup fix + smoke + verification
CI / build (push) Failing after 2m8s
Adds adapter-owned externalEventId extraction to the ChannelAdapter contract
(WebhookAdapter→eventId, EmailAdapter→Message-ID) and uses it in InboundService,
so email replays dedup on Message-ID (previously only payload.eventId worked).
Adds an email-dedup spec + smoke-email.mjs proving inbound → normalized, a reply
joins the same email thread, bad signature rejected, and outbound EMAIL SENT.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 20:34:26 +05:30
maaz519 f0afca89e3 feat(iios): register EmailAdapter for EMAIL + email-shaped egress provider
AdapterRegistry now backs the EMAIL channel with the email-native EmailAdapter
(RFC threading) instead of the generic WebhookAdapter. Adds EmailProvider — an
email-envelope egress provider ({to,subject,text,html,inReplyTo}) registered for
EMAIL when IIOS_PROVIDER_URL_EMAIL is set (sandbox stays the default). Spec:
signed email → normalized interaction on the email thread; a reply joins the
same thread (one conversation); bad signature → rejected (fail-closed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 20:28:35 +05:30
maaz519 427396653f feat(iios): socket.io Redis adapter for cross-replica realtime (P9 scaling)
Adds an opt-in RedisIoAdapter (wired in main.ts when REDIS_URL is set) so
socket.io room emits fan out across instances via Redis pub/sub — a client on
replica B now receives messages emitted by replica A. Without REDIS_URL the
in-memory adapter is kept (single-instance dev unchanged). Adds redis to
docker-compose, REDIS_URL to the env contract, and smoke-realtime-cluster.mjs
which proves cross-instance delivery fails without Redis and passes with it.
Delivery is at-least-once at N>1; clients dedupe by message id (documented).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 19:28:11 +05:30
maaz519 a520b26398 chore(iios): production Dockerfile + migrate entrypoint + env/secrets contract
Adds a multi-stage Dockerfile for iios-service (build → pnpm deploy prune →
slim non-root runtime), a docker-entrypoint that runs `prisma migrate deploy`
then starts the server as PID 1, a .dockerignore, a fully-commented
.env.example config contract, and docs/DEPLOYMENT.md (topology, scaling,
release strategy, prod-readiness gaps). Moves prisma to dependencies so the
CLI ships in the prod bundle. Image builds, migrates, and serves /health 200.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 19:19:13 +05:30
maaz519 a933e2e3af feat(iios): idempotency ledger on outbound egress (409 on key reuse) (P9)
OutboundService.send is now opt-in idempotent: an explicit idempotencyKey routes
the send through IdempotencyService.run (Slice 6), so reusing a key with a
different target/payload returns a 409 conflict and a same-key/same-request retry
replays the cached command. The inner findUnique early-return stays as a backstop
so a FAILED-then-retried send never collides on the command's unique key. Keyless
sends keep the old behavior (no ledger row).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 18:44:22 +05:30
maaz519 cdba0f0179 feat(iios): retention sweep dev endpoint + /metrics + smoke (P9)
POST /v1/dev/retention/sweep runs the sweep on demand; /metrics gains a global
retention section (snapshot counts by status). smoke-retention.mjs (0-day
windows) proves an aged interaction is redacted in place and surfaces as a
REDACTED snapshot on /metrics.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 12:57:51 +05:30
maaz519 035315ef73 feat(iios): retention_policy_snapshot + sweep (archive→redact, hold-aware) (P9)
Adds IiosRetentionPolicySnapshot (per-interaction frozen lifecycle timestamps)
+ IiosInteraction.dataClass, and a RetentionService whose sweep captures
snapshots then archives (status change) and, past delete_after, redacts content
in place (reusing the Slice-8 tombstone) — never a hard delete. An active
compliance hold blocks both; every action is audited and tenant-scopeable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 12:50:49 +05:30
maaz519 a5cd8f0ec4 feat(iios): /v1/dsr erasure + compliance-hold REST + smoke (P9)
Tenant-scoped /v1/dsr: POST /erase (self-service right-to-erasure), POST/GET
/holds + POST /holds/:id/release. smoke-dsr.mjs proves PII present → hold
blocks erase (409) → release → erase redacts the ticket subject in place →
re-erase idempotent → cross-tenant cannot see the hold.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 06:27:42 +05:30
maaz519 611ca74cea feat(iios): DSR right-to-erasure (redact-in-place) + compliance hold (P9)
Adds IiosComplianceHold + IiosSourceHandle.redactedAt and a DsrService whose
eraseSubject redacts the caller's PII in place (message bodies, identity
display, raw payloads, support/inbox/transcript text) while preserving
externalId (source preservation), structure, and the audit trail. An active
compliance hold blocks erasure until released; every op is audited and
tenant-fenced. Erasure is idempotent (KG-03).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 03:24:43 +05:30
maaz519 67da42a103 feat(iios): projection-cursor observability on /metrics + smoke (P9)
Adds a global `projections` section to GET /metrics exposing each cursor's
position (last_event_id/last_offset), determinism checksum, and lagMs.
smoke-projection.mjs injects events and asserts the projection's ordered
high-water-mark advances with a 64-hex rolling checksum (KG-06).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 02:48:14 +05:30