Skip to content

Release: merge develop into main - #71

Merged
jhosepmyr merged 83 commits into
mainfrom
develop
Jul 10, 2026
Merged

Release: merge develop into main#71
jhosepmyr merged 83 commits into
mainfrom
develop

Conversation

@jhosepmyr

Copy link
Copy Markdown
Contributor

Summary

Merges develop into main to ship everything since the last release cut. Billing/Stripe backend code (subscription lifecycle, token quota, payment gateway) was already on main from an earlier merge — this release brings the pieces built since then:

ecs/task-definition.json changes from the two hotfix/sync-*-develop merges (#59, #62) are already reflected on main (they were develop catching up to hotfixes applied directly to main) — no drift expected there.

Test plan

  • CI (build + verifyModularity) green on this PR
  • After merging, confirm deploy.yml registers a new ECS task definition revision and the service comes up healthy
  • Smoke-test the Jira OAuth connect flow end to end in production
  • Smoke-test a Stripe checkout redirect (test-mode key) end to end in production
  • Confirm https://api.tamci.app/actuator/health stays healthy after rollout

jhosepmyr added 30 commits July 7, 2026 00:23
Discovery's live-session presence needs to label participants by their
member display name, but members are a workspace-owned aggregate.
Adds findMemberDisplayName(orgId, userId) to the WorkspaceModuleApi
named interface (reads public.members, no tenant schema required) so
discovery resolves names through the ACL instead of reaching in.
STOMP session-lifecycle listeners run on the broker thread, which has
no bound TenantContext. Persisting the verified orgId in the session
attributes on CONNECT lets those listeners (discovery presence)
resolve the tenant without re-parsing the JWT.
Introduces the SessionParticipant record and the SessionPresenceMessage
snapshot (added to the sealed SessionRealtimeMessage hierarchy and the
SessionEventType discriminator). The roster travels on the existing
per-session topic so the client keeps a single subscription and switches
on type, consistent with the other session events.
Adds an in-process presence layer for the discovery module:
SessionPresenceRegistry keeps who is subscribed to each session topic,
SessionPresenceTracker drives it from STOMP subscribe/unsubscribe/
disconnect events and rebroadcasts the roster snapshot, and
SessionParticipantResolver labels participants via the workspace ACL
with a Caffeine cache. No Redis: presence is per-connection JVM state,
same single-instance trade-off as the SIMPLE broker.
Verifies join/leave semantics, multi-tab dedup (a user counts once and
stays present until their last connection leaves), disconnect fan-out
across sessions, and unknown-connection no-ops. Switches the inner
presence map to a LinkedHashMap so the roster keeps a stable join order.
Adds a presence section to REALTIME.md (STOMP-event-driven, in-process
registry, single-topic PRESENCE_STATE, multi-instance caveat) and a
CHANGELOG entry under [Unreleased] for feature/discovery-presence.
Adds SessionParticipantResolverTest (name + deterministic avatar url,
Caffeine caching hits the workspace once, generic fallback when the
membership is unknown) and SessionPresenceTrackerTest (subscribe to a
session topic broadcasts the roster, unrelated destinations are
ignored, unsubscribe/disconnect rebroadcast an empty roster).
The Principal set via accessor.setUser() on the CONNECT frame does not
carry over to later frames on the same STOMP session in practice — a
SUBSCRIBE frame's accessor.getUser() comes back null, verified against
a real running instance. Session attributes (already used for orgId)
are the WebSocketSession's own attribute map and do persist across
every frame, so the verified userId is now stashed there too.
SessionPresenceTracker relied on accessor.getUser() to identify the
subscriber, which is null on SUBSCRIBE/UNSUBSCRIBE frames (see the
StompAuthChannelInterceptor fix). This is why no presence roster ever
appeared, even for a session's very own recorder. Reads userId from
the same session-attribute mechanism already used for orgId instead,
and adds a regression test pinning this exact failure mode. Verified
end-to-end against a running instance: PRESENCE_STATE now broadcasts
correctly with the resolved member name.
The doc claimed the CONNECT-bound Principal is available on later
frames ("so ... per-message security work"); empirically it is not.
Documents the actual mechanism (session attributes) so the next person
extending @MessageMapping or per-user routing doesn't rediscover this
the hard way.
Jackson's record serializer only emits canonical record components;
type() was a plain interface-method override returning a hardcoded
constant, not a component, so it was silently dropped from the JSON.
The client's message.type === 'PRESENCE_STATE' switch therefore never
matched even though the message itself arrived correctly over the
WebSocket. Matches the existing @JsonProperty("type") idiom already
used by SessionProcessingFailedMessage/SessionStoryGeneratedMessage/
SessionTranscriptSegmentMessage for the same fixed-type-constant case.
Verified against a running instance: the wire payload now includes
"type":"PRESENCE_STATE".
The existing presence tests all inspected the Java object directly via
Mockito captors, so none of them would have caught the missing "type"
JSON field — that requires a real Jackson pass over the actual wire
output. Adds a parameterized test asserting every SessionRealtimeMessage
implementation serializes a "type" matching type(), plus a reflection
check that SessionPresenceMessage.type() stays @JsonProperty-annotated.
Confirmed both fail against the pre-fix code (2 failures), pass after.
CI's "Integration tests" job was failing: RealtimeNotificationIntegrationTest
and SuggestionBroadcastIntegrationTest each subscribe to a session topic and
assume the first frame received is the one they triggered. Since presence
now auto-broadcasts a PRESENCE_STATE message the instant any client
subscribes to that same topic, that broadcast could land first and get
misdeserialized into the test's expected type (its JSON still decodes
cleanly — extra/missing fields are just ignored), racing the real message.
Each subscribe() helper now filters frames by the expected SessionEventType
before enqueuing, which is the correct fix given this codebase's existing
single-topic/multiple-message-kinds design (client already switches on
type) rather than a workaround.
feat(discovery): live session presence over STOMP
Now that api.tamci.app and app.tamci.app have real ACM certs wired
into the ALB and CloudFront (reqsai-infra), the task definition should
reference those instead of the raw ELB/CloudFront default hostnames.
fix: sync tamci.app domain URLs into develop
Gmail requires the From header to match the authenticated account
when no alias is configured. Reads the same secret key as
MAIL_USERNAME instead of hardcoding an address, so rotating SMTP
credentials never needs a code change.
…tions and V22 targets

One table per migration script: V21 creates integration_connections (org-scoped
credentials), V22 creates project_integration_targets (project-scoped push routing).
No schema change — the combined V21 is split for clarity and ordering.
introduce CredentialType {API_TOKEN, OAUTH2} and extend IntegrationConnection
with cloudId + encrypted oauth refresh/access tokens and expiry, plus a factory
for oauth connections and a rotated-token updater. migration V23 adds the columns
additively (default API_TOKEN) and relaxes email/secret_ciphertext to nullable.
IntegrationConnectionResponse now carries credentialType and email may be null.
jhosepmyr added 22 commits July 10, 2026 07:12
Replaces the hand-rolled async worker with two chunk-oriented batch jobs (jiraImportJob / jiraPushAllJob): step-scoped readers resolve the work list and plan the projection total, processors delegate per item to the existing import/push services, a fault-tolerant skip policy preserves the per-item failure semantics, and job/step listeners restore the tenant from job parameters, update the integration_sync_jobs projection and publish each snapshot over STOMP. The launcher adapter behind the IntegrationJobLauncher port captures the tenant into job parameters and starts executions asynchronously via the JobOperator.
…ints

POST import / push-all now validate the target, persist a RUNNING integration_sync_jobs row (409 INTEGRATION_JOB_ALREADY_RUNNING on a concurrent start) and return 202 with the IntegrationJobResponse snapshot; new GET jobs (?active=true) and GET jobs/{jobId} endpoints (INTEGRATION_READ) serve reload recovery. Removes the synchronous batch result DTOs.
Unit tests for the job counters/terminal transitions, the single-running-job 409 (pre-check and unique-index race), tenant capture into job parameters and restoration by the job listener, per-item progress accounting including skip-policy failures, and the import outcome mapping.
Extends ADR-0023 with a didactic section on the Spring Batch engine (job/instance/execution, chunk step, skip policy, public-schema metadata in the multi-tenant setup, projection-over-BATCH-tables rationale, tenant propagation), records the 202 job contract in the changelog and refreshes the Jira guide. Also removes a stray code fence at the ADR tail.
DevTokenController mints a signed JWT for any user/org/role with no
login (a bootstrap tool for exercising authenticated endpoints before
the real login existed). It's @Profile("dev")-gated so its bean never
registers elsewhere, but that annotation is invisible to Spring
Security's filter chain — the /api/auth/** wildcard would have made
the path fall through to permitAll regardless of profile if the bean
ever did register outside dev (e.g. a profile misconfiguration in a
shared environment). Adds an explicit, profile-aware rule evaluated
before the wildcard: permitAll only when the dev profile is active,
denyAll otherwise.
The STOMP realtime test filtered frames by message.type(), but every concrete
SessionRealtimeMessage hardcodes type() as a fixed constant (it is a
@JsonProperty getter, not a canonical record component). Force-casting any frame
into the expected payload type therefore made type() lie: the automatic
PRESENCE_STATE broadcast a client receives the instant it subscribes deserialized
into e.g. SessionProcessingFailedMessage with type()==FAILED and a null reason,
racing (and under CI load beating) the real message and failing the assertion.

Read each frame as a raw Map, discriminate on the WIRE type discriminator, and
convert only genuine matches (ignoring the type/presence-only fields that are not
record components). Behaviour-neutral, test-only.
feat(gateway): Jira integration — OAuth 2.0 + API token, org connection & project story push
They required a JWT (never fully public), but any authenticated
tenant user could reach them — more exposure than intended for
internal architecture (modulith) and operational data (metrics).
There's no platform-wide "ops" role in this codebase to gate them
behind (authorization here is entirely tenant/org-scoped via
@authz.*), and nothing consumes them today (no Prometheus/scraper,
logs go to CloudWatch only; info returns nothing without
management.info.* contributors configured). Not exposing them over
HTTP at all is simpler and safer than inventing a role just for this.
Only health stays exposed, still gated by show-details: when-authorized.
…oint

fix: block /api/auth/dev-token outside the dev profile
JIRA_OAUTH_CALLBACK_URL derived from the same frontend domain as
FRONTEND_URL (not a separate literal, can't drift out of sync).
INTEGRATIONS_ENCRYPTION_KEY, JIRA_OAUTH_CLIENT_ID,
JIRA_OAUTH_CLIENT_SECRET, and JIRA_OAUTH_STATE_SECRET read from the
new reqsai/production/jira secret (reqsai-infra), matching the
existing jwt/smtp/ai secrets pattern — the execution role's read
permission for it was already granted via Terraform.
feat: wire Jira OAuth config into the ECS task definition
Matches the pattern already used by Stripe's checkout success/cancel
URLs (application.yml) — JIRA_OAUTH_CALLBACK_URL becomes an optional
override instead of a required literal, so it can no longer drift out
of sync with FRONTEND_URL. Drops the now-redundant explicit value from
the deployed task definition.
fix: default Jira OAuth callback URL to FRONTEND_URL
BILLING_PAYMENT_PROVIDER=stripe flips off the fake in-memory gateway.
WEB_APP_URL (same value as FRONTEND_URL) feeds the checkout
success/cancel URL derivation already in application.yml, so no
explicit STRIPE_SUCCESS_URL/CANCEL_URL is needed. STRIPE_API_KEY and
STRIPE_WEBHOOK_SECRET read from the new reqsai/production/stripe
secret (reqsai-infra), matching the existing jwt/smtp/ai/jira pattern.
feat: wire Stripe billing config into the ECS task definition
The Stripe/subscription billing feature (015aa9e, 95127c1, 938e450,
29b05fc, 8a40d8b, 70a47b6) was never recorded under [Unreleased].
Also notes JIRA_OAUTH_CALLBACK_URL's new FRONTEND_URL-derived default.
…-unreleased

docs: document Stripe billing and Jira callback default in CHANGELOG
…efinition-merge-conflict

# Conflicts:
#	ecs/task-definition.json
…-merge-conflict

fix: resolve ecs/task-definition.json merge conflict with main
@jhosepmyr
jhosepmyr merged commit 468daab into main Jul 10, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant