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>
Design + 8-task TDD plan for the IIOS template module. Self-reviewed:
provenance-write path, nullable-scope unique index, PII minimization.
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>
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>
- .npmrc routes the @insignia scope to https://git.lynkedup.cloud/api/packages/insignia/npm/
(auth via ${GITEA_TOKEN} env — no secret committed).
- The 9 frontend SDK packages (contracts, kernel-client, adapter-sdk, *-web) are now
publishable: private dropped, version 0.1.0, publishConfig pinned to Gitea. iios-service
and iios-testkit stay private (pnpm publish skips them).
- Root `release` / `release:dry` scripts; a Gitea Actions workflow publishes on a v* tag.
- PUBLISHING.md documents publish + consumer (.npmrc) setup.
Verified: dry-run packs cleanly and workspace:* deps resolve to 0.1.0 in the tarball.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Versioned, per-topic project memory (index + entries): IIOS overview, the generic-safety
rule, run/test + the replay.spec flake workaround, recent features (Supabase auth,
reactions/pins/saves, mentions, media, notifications), the chat-web consumer, and workflow
conventions. CLAUDE.md points to it as the accumulating-notes companion to the canonical rules.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Captures what IIOS is, the #1 generic-safety guardrail (no chat vocabulary in the
kernel; meaning lives in OPA policy + opaque attributes + the app), the architecture
(kernel + platform ports + fail-closed + outbox/projectors), tech stack, run/test
commands (isolated iios_test DB, the replay.spec flake note), conventions (commit
email, IIOS_DEV_TOKENS off in prod), and current build state.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
API & SDK guide:
- §5.3 Threads & Messaging fully filled in: GET/POST /v1/threads, participants,
my-annotations; the Message shape (senderId, attachment, annotations); the full
socket event set (open_thread/send_message/add_participant/annotate/read/typing +
server message/receipt/typing/annotation); reactions/pins/saves as the generic
interaction-annotation primitive; mentions → MENTION inbox item.
- §5.4 inbox MENTION kind; SDK reactions/pins/saves + listThreads/createThread;
vocab adds MENTION, message-part kinds, attachment kind, annotation types.
DEPLOYMENT:
- Real-IdP auth env (SUPABASE_URL/AUTH_ISSUERS via JWKS, no secret) + MEDIA_SECRET.
- Media StoragePort in topology: dev local disk → prod object storage; ⚠ local disk is
single-instance/non-durable; bytes flow client↔storage direct (scales independently).
CEO overview: refreshed counts (192 tests, 53 tables / 19 migrations) and noted P9 has
begun — real Supabase auth (first stubbed port turned real) + generic reactions/pins/
saves/mentions/media on the kernel.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- New §5.10 Media: presign-upload/download + upload/blob endpoints, attachment shape,
governance notes (25MB + type allowlist via OPA, signed tokens, tenant fence), and a
sequence diagram of the presign→upload→send→signed-download flow with the OPA gate.
- SDK: uploadMedia/mediaUrl; send() attachment/mentions.
- Auth §3: real OIDC (Supabase/AUTH_ISSUERS) JWKS verification alongside dev HS256.
- Env: MEDIA_DIR/MEDIA_SECRET/PUBLIC_URL/SUPABASE_URL/AUTH_ISSUERS; test count 192.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
- 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>
- 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>
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>
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>
The declarative services: Postgres was never reachable at postgres:5432 (P1001)
on this Gitea runner even with a valid healthcheck — service-name networking to
the job isn't wired here. Switch to the mdm CI pattern: host-mode runner, start
pgvector via 'docker run -p 5434:5432', connect over localhost:5434 (also the
specs' default DATABASE_URL). Clean up the container in always().
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A quoted '--health-cmd "pg_isready -U iios"' in the folded options scalar
splits on spaces, so docker received an invalid healthcheck command; the
service never went healthy and the job container couldn't resolve postgres:5432
(P1001). Match go-monorepo's proven unquoted '--health-cmd pg_isready'.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The integration *.spec.ts suites need a live Postgres (schema applied) at
DATABASE_URL. Run on the dind-builder runner in a job container with a
pgvector/pgvector:pg16 service (proven pattern from go-monorepo CI), point
DATABASE_URL at the service, and 'prisma migrate deploy' before pnpm test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pnpm -r typecheck ran before pnpm -r build, but the @insignia/* packages
publish their types only via built dist/*.d.ts. So consumers failed with
TS2307 'Cannot find module @insignia/iios-contracts' before any dist existed.
Also generate the iios-service Prisma client so its schema-derived types
resolve. Build now precedes typecheck; prisma:generate precedes both.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
Adds a canonical EmailInboundPayload + EmailAdapter implementing the
ChannelAdapter contract: HMAC signature verify + normalize with email-native
threading (whole reply chain → one IIOS thread via references/inReplyTo root),
messageId as providerEventId for dedup, html→text fallback, Re:/Fwd: stripping.
Plus emailInboundFixture/emailReplyFixture and unit tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>