Skip to content

feat(social): C2 Bluesky connect (#641) - #1439

Merged
radandevist merged 18 commits into
developfrom
lane/wt-641
Aug 25, 2026
Merged

feat(social): C2 Bluesky connect (#641)#1439
radandevist merged 18 commits into
developfrom
lane/wt-641

Conversation

@radandevist

@radandevist radandevist commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

What

Epic C step 2 — Bluesky connect for tenants (#641), part of the social-accounts epic (#630). A tenant admin can connect a Bluesky account with an identifier + app password, reconnect it after a password change, disconnect it (secret erased), scope its visibility to projects, and list accounts with keyset pagination.

Closes #641 · refs #630

Per-area changes

Infrastructure seam + client (Modules/SocialAccounts/Providers/Bluesky/, Lib/)

  • IBlueskyClient HTTP adapter over Bluesky createSession with typed outcomes: Success(Identity, AccessJwt, PdsHost) / AccountFailure(reason) / Transient — secrets never leave the request body; 401→refused, 400-class→account-not-found, network/5xx→transient.
  • BlueskySessionProvider implements the Epic-D ISocialSessionProvider.OpenSessionAsync seam exactly (identifier = stored handle, secret decrypted only in-flight).
  • FakeBlueskyClient records every attempt and programs outcomes; fakes in every spec, never real network.

Rate limiting (Lib/RateLimiting/, env)

  • New SocialConnect policy (default 5 permits / 3600 s per session fingerprint), stricter than reads by design (spec §4). Wired through AppEnvironment (SOCIAL_CONNECT_RATE_LIMIT_*), .env.example, settings record, limiter store, coverage validator.

Domain service (Services/SocialAccountService.cs)

  • Connect: upsert-by-(tenant, provider, DID); refusal/unreachable → nothing stored; same-DID re-connect reactivates in place.
  • Reconnect: replaces the secret only on provider success; refused → row untouched; revoked → 404.
  • Disconnect: status → Revoked + ProtectedCredentials erased to empty sentinel; soft delete untouched (history kept).
  • SetProjects: replace-all diffing validated against the tenant's live projects; empty set = visible everywhere.
  • Find: keyset pagination via CursorSortFieldHandler (created_at/updated_at) + optional project visibility filter. Project links are loaded before VisibleIn runs — under AsNoTracking an unloaded navigation made every account look unattached and leaked attached accounts across projects; caught first by spec, then covered again by proof 3.

API surface (Routes.SocialAccounts.cs, Endpoints/, Handlers/Tenant/)

  • /social-accounts group: GET list (view, HeavySearchList), POST /connect (manage, SocialConnect), POST /{id}/reconnect (manage, SocialConnect), POST /{id}/disconnect (manage, default), PUT /{id}/projects (manage, default).
  • New RFC 7807 result type AppProviderUnavailableHttpResult (+ TypedProblems.ProviderUnavailable) — 503 reserved strictly for downstream provider outages.
  • Audit constants socialaccount.connected/reconnected/disconnected/projects.set; details carry handle/DID/project ids, never the app password.
  • i18n keys EN+FR (credentials-refused, provider-unreachable, social-account-not-found, social-account-disconnected-success, project-not-found) + regenerated ResponseKeys.g.cs.
  • Permissions slice (tenant.socialaccounts.view/manage/publish) landed earlier on this branch.

Client

  • Kiota regeneration: new packages/client-ts/src/socialAccounts/ surface; front typecheck green.

Verification

All heavy commands through ~/ai-orchestration-playbook/tools/heavy.sh:

  • just build-api — green (0 warnings).
  • Service specs: 16/16 (SocialAccountService.*Spec, exclusive ApiFixture per class, at-rest secrets verified through an independent DbContext).
  • Architecture + ComprehensiveRateLimiting guards: 107/107 (includes endpoint rate-limit coverage validation of the five new routes).
  • Six mandated proofs (.dump/mutation-check.md sibling evidence): secret-leak sweep (list/detail routes, every DB column, audit_log rows; blob round-trips but plaintext appears nowhere), tenant isolation (B on A's ids → 404 everywhere; lists disjoint), project visibility over HTTP, permission matrix (403 ×5 routes without verb, 401 without session), refusal→nothing-stored (422 then 503, zero rows; success inserts exactly one), disconnect semantics (revoked + Unprotect.Outcome == Absent, still listed, reconnect-after-revoke → 404). 8/8 green.
  • Adversarial mutation (.dump/mutation-check.md): dropping the tenant filter fails the build outright (IDE0060 — parameter unused); neutralizing it with || true turns SocialAccountIsolationSpec red with tenant B successfully mutating A's account. Restored byte-exact (SHA-256 verified), suites green again.
  • Full API suite just test-api: 1992/1992 passed (log: .dump/full-api-suite.log).
  • just generate-client && pnpm --filter front typecheck: green.
  • just check-write: clean. just knip: fails identically on this branch's baseline without our diff (pre-existing findings in scripts-ts/shared-ts; zero hits touching the social slice).

Note: the brief cited just api-check, which does not exist in the justfile; substituted just build-api + targeted suites + full just test-api as above.

Risk / rollout

No DB migration in this step (entity + junction shipped earlier). Deployed env needs no new required variables — SOCIAL_CONNECT_RATE_LIMIT_* have safe defaults. The Bluesky client is behind an interface faked in tests; production registration requires SOCIAL_ACCOUNTS_MASTER_KEY already present since the Epic-D canary work.

Round 2 (review fixes)

Round-1 verdict was CHANGES_REQUIRED (2 MAJOR blocking + 3 MAJOR follow-ups) — all five addressed, one commit each, merged cleanly with origin/develop first. Paired RED/GREEN mutation evidence per finding under .dump/.

  1. BlueskySessionProvider spec (b85ec4ef7) — new BlueskySessionProviderSpec drives every outcome through FakeBlueskyClient: success → Opened(Did, Handle, AccessJwt, PdsHost); unprotect absent/tampered → AccountFailure (plain-words cause); missing row → AccountFailure; refused → AccountFailure plus persisted flip Status→NeedsReconnect, LastError=sanitised cause, UpdatedAt (ExecuteUpdateAsync, asserted via fresh AsNoTracking context); transient → Transient with zero stored-state change. Proof: swapping the Transient/AccountFailure mapping → RED 2/5 (session-provider-red.log), restored → GREEN 5/5.
  2. Record secret redaction (4090ed980) — BlueskyCredentials and SocialSession override ToString()/PrintMembers rendering the app password / access JWT as [REDACTED]; AccessJwt additionally [property: JsonIgnore] so serializer-based logging cannot leak it. Positional ctor unchanged (D1 seam intact). New SocialSessionSecretRedactionSpec asserts ToString(), interpolation, reflection PrintMembers and JSON output never contain the secret value: RED 0/3 before the overrides, GREEN 3/3 after (redaction-red.log / -green.log).
  3. SocialConnect exhausted over HTTP (f6ad50979) — new ComprehensiveRateLimitingSpec case uses the previously unused socialConnectPermitLimit: 1 factory parameter and drives two real POSTs to /social-accounts/connect: first 201, second 429 application/problem+json. Proof: detaching the policy from the connect endpoint (fallback AuthenticatedDefault lets the second request pass) → RED 0/1, re-attached → GREEN 1/1 (rate-limit-red.log / -green.log). The Verification claim above is corrected accordingly.
  4. i18n parity guard (6eb6ce61d) — packages/shared-ts vitest spec pins full EN↔FR key parity of the response-message namespace and names the five new keys explicitly with non-empty values both sides. Proof: removing an FR key → RED, restored → GREEN (i18n-red.log / -green.log).
  5. Plan relocated (382b7f9c2) — git mv docs/superpowers/plans/2026-08-25-c2-bluesky-connect.md docs/records/2026-08-25-plan-c2-bluesky-connect.md; self-reference fixed; stale design-spec link replaced by a pointer to the prune audit record; emptied superpowers dirs removed.

Post-fix full API suite: 2047/2047 passed (.dump/full-api-suite-r2.log).

Round 3 (spec-drift + develop/D1 convergence)

Round-2 verdict was CHANGES_REQUIRED with ONE blocking finding: the spec-drift REQUIRED gate was RED (committed kiota-lock.json stale vs committed openapi.json).

  1. Spec-drift fix (939340af1) — packages/client-ts/src/kiota-lock.json regenerated from the committed apps/api/openapi.json via just build-api && just generate-client. The descriptionHash flips 17ADE7D5… → 6DFA216E…, byte-for-byte the hash the reviewer derived independently. Determinism proof: a second clean just build-api && just generate-client at the new tip left git status completely empty (zero diff, lock file was the only changed artifact).
  2. develop merge (863b51118) — merges origin/develop at 2ef6872b0, including D1 (feat(publishing): D1 — Publication model, IPublishProvider, Bluesky createRecord with a deterministic key #1433, 12938d937) which shares the seam files. Only one content conflict: the seam file itself, where both branches define the same types. Resolved to ONE definition (no duplicates survive): the enriched ISocialSessionProvider.cs keeps D1's contract byte-identical — positional ctor SocialSession(Did, Handle, AccessJwt, PdsHost) unchanged, so every D1 consumer (PublishPublicationJobHandler, BlueskyPublishProvider, their specs) compiles as-is — while retaining the round-2-F2 secret redaction ([property: JsonIgnore] on AccessJwt plus ToString()/PrintMembers rendering it [REDACTED]), so the reviewed security fix is not regressed by the convergence. DI converges by the mechanism D1 documented for exactly this moment: develop's UnimplementedSocialSessionProvider registers through TryAddScoped, so this lane's real AddScoped<ISocialSessionProvider, BlueskySessionProvider>() wins last-wins with no code change; the placeholder remains only as a fail-loud backstop.
  3. Gates at the merge tip (all heavy commands under heavy.sh, canonical test env values from .env.example):
    • API suites filtered to SocialAccounts + Publishing modules plus the D1 Roslyn guards (PublicationArchitecture.Spec, AppRoleComposition.Spec, ServiceAttributeRegistration.Spec, ServiceArgsRecordConvention.Spec): 233/233 passed.
    • pnpm --filter front typecheck: green. Full front test chain (vitest + all guards incl. design-system, z-index, context-chunk isolation, font bundle, react-compiler artifact guard): green — 101 compiler-compiled modules ≥ floor 72 after a fresh pnpm --filter front build.
    • pnpm lint: green.
    • Contract drift after the merge: zero diff (just build-api && just generate-client at the merge tip, clean tree).
  4. CI at the pushed tip (863b51118) — polled to completion only (gh pr checks, every check terminal before reading results): all 28 checks pass, zero failures, including the previously-red spec-drift and its openapi-spec-drift-gate summary, both e2e suites (Build e2e images / 4× front-e2e / front-e2e-gate), quality, supply-chain, react-doctor-gate.

Model

Model: Ox Alpha via Nous Portal (jcode), effort max (three provider deaths, resumed from on-disk state each time — see .dump/resume-*.md). Reviewer: adversarial round 1 pending (free-first chain). Model line added by the captain from the dispatch ledger; the lane self-reported nothing.

Plan for Epic C delivery step 2 (spec 2026-08-22-epic-c §6 item 2):
- Bluesky session provider behind an infrastructure seam
  (IBlueskySessionProvider) so SocialAccountService keeps its
  dependency boundary (DbContext + infrastructure only)
- three tenant permissions (socialaccounts.view/manage/publish)
- routes: list (keyset), connect, reconnect, disconnect,
  projects replace-all (attach/detach)
- dedicated stricter SocialConnect rate-limit policy for the two
  routes that call Bluesky
- audit actions for connect/reconnect/disconnect/projects changes
- six mandated proof specs incl. secret-leak sweep, isolation 404,
  visibility, per-route permission refusals, refusal-stores-nothing
- Kiota client regeneration

No migration expected: C1-bis tables already carry everything C2 needs.
)

Epic C §1 decision 5: three tenant permissions assigned through
profiles like every other verb — tenant.socialaccounts.view,
.manage, .publish. Tenant admins hold them implicitly.

- SocialAccountPermissionsForTenant slice (EN+FR copy) wired into
  TenantScopePermissions so PermissionSeeder picks it up by reflection.
- FindTenantPermissionsSpec expected catalog extended: 15 groups,
  47 keys; full-suite green locally including both production-role
  composition/seeding probe specs after supplying the local
  TRUSTED_PROXY_CIDRS the probes require.
… session seam + fake (#641)

- IBlueskyClient/BlueskyClient: com.atproto.server.createSession over a named
  typed HttpClient; 401 -> AccountFailure(credentials refused), 400-class ->
  AccountFailure(account not found), network/timeout/5xx/malformed -> Transient;
  the app password exists only in the outgoing request body and never in any
  returned reason.
- ISocialSessionProvider/BlueskySessionProvider: the exact Epic-D seam from the
  brief; resolves the stored credential through ICredentialProtector, opens the
  session, maps outcomes. Registered manually ([Service] scanner only accepts
  *.Services namespaces with I{ClassName} contracts).
- FakeBlueskyClient for specs; ApiFactory replaces IBlueskyClient with it.
- BlueskyClientSpec: classification matrix + secret-hygiene assertions (8 specs).
… for Bluesky routes (#641)

Spec §4: connect/recontact call Bluesky with user-supplied credentials, so they
get a dedicated policy (default 5 permits / 3600 s) instead of AuthenticatedDefault.
Wired through ApiRateLimitSettings + AppEnvironment (SOCIAL_CONNECT_RATE_LIMIT_*)
+ .env.example template + limiter store + options setup; IsKnown and
UsesValidatedSessionPartition cover it. Rate-limit specs green (41).
…set-projects

Unit C of Epic C step 2 (Bluesky connect, #641):

- Connect: upsert by (tenantId, provider, DID); Bluesky refusal or
  transient outage stores NOTHING (spec \u00a76); same-DID reconnect after
  disconnect reactivates in place with a fresh protected secret.
- Reconnect: replaces the secret only on Bluesky success; refused
  attempts leave the stored row untouched; revoked accounts are 404.
- Disconnect: status -> Revoked, ProtectedCredentials erased to empty;
  soft delete untouched so history stays.
- SetProjects: replace-all attachments validated against the tenant's
  live projects (cross-tenant project ids rejected); empty set = visible
  everywhere.
- FindForTenant: keyset pagination via CursorSortFieldHandler
  (created_at/updated_at), tenant-scoped, optional project visibility
  filter. Project links are loaded BEFORE VisibleIn runs - with
  AsNoTracking an unloaded navigation made every account look unattached,
  which leaked attached accounts across projects (caught by spec).
- Service registered scoped in ServiceRegistration.
- 16 service specs over ApiFixture + FakeBlueskyClient (exclusive fixture
  per class); independent DbContext verification for at-rest secrets.
Unit D of Epic C step 2 (Bluesky connect, #641):

- Routes.SocialAccounts: /social-accounts tenant group (find GET,
  connect POST, {id}/reconnect POST, {id}/disconnect POST,
  {id}/projects PUT).
- Five handlers following the Posts handler shape: auth-context tenant
  parsing, cached JsonElement getters (PUBLY0006), TypedProblems
  mapping. Connect/reconnect ride the SocialConnect limiter; find rides
  HeavySearchList; disconnect/projects ride AuthenticatedDefault.
  Permissions: view on find, manage on all mutations.
- New AppProviderUnavailableHttpResult + TypedProblems.ProviderUnavailable:
  RFC 7807 503 reserved for downstream provider outages (transient),
  auto-documented in OpenAPI.
- Audit constants socialaccount.connected/reconnected/disconnected and
  socialaccount.projects.set; details carry handle/DID/project ids only,
  never the app password.
- i18n keys EN+FR (credentials-refused, provider-unreachable,
  social-account-not-found, social-account-disconnected-success,
  project-not-found) and regenerated ResponseKeys.g.cs.
- Program.cs maps MapSocialAccountEndpointsForTenant() under tenantGroup.

Architecture + ComprehensiveRateLimiting guard suites green (107 specs).
Unit E of Epic C step 2 (Bluesky connect, #641):

- SecretLeak: connect with a known password, sweep list/detail routes,
  every social_accounts column and all audit_log rows; blob decrypts
  back to the password but never appears in plaintext anywhere.
- Isolation: tenant B credentials on A's account id -> 404 on
  reconnect/disconnect/projects, A's account absent from B's list,
  A's row untouched.
- Visibility: unattached listed under both project filters, attached-to-X
  under X only, both unfiltered.
- Permissions: no-permission seeded user gets 403 on all five routes;
  missing session gets 401.
- Refusal: fake AccountFailure -> 422 with zero rows; Transient -> 503
  with zero rows; success then inserts exactly one row.
- Disconnect: revoke + secret erased (Unprotect Outcome=Absent), still
  listed, reconnect-after-revoke -> 404.

Adversarial mutation (transcript in .dump/mutation-check.md): dropping
the tenant filter fails the build (IDE0060); neutralizing it with
'|| true' turns SocialAccountIsolationSpec red with tenant B mutating
A's account. Restored byte-exact (sha256 verified); suites green again.
Unit F of Epic C step 2 (#641): just generate-client after the new
/social-accounts tenant routes. Adds packages/client-ts/src/socialAccounts/
plus regenerated models/apiClient/kiota-lock. pnpm --filter front
typecheck green against the regenerated client.
radandevist added a commit that referenced this pull request Aug 25, 2026
docs(records): C4 pause & resume + reconnect banner implementation plan

Implementation plan for C4 (pause / resume a connected social account, and the
"reconnect needed" banner), written in the writing-plans format under
docs/records/2026-08-25-plan-c4-pause-resume-reconnect.md. Round 2 replaced every
fabricated API with the real one read from develop, the C2 branch (PR #1439) or the D1
branch (PR #1433), moved the plan out of the pruned docs/superpowers/ tree, rewrote the
status-write guard to assert real forbidden shapes and fail loud, removed the
domain-service → domain-service dependency, and added the rate-limit policy and
client-generation steps. Executable once C2 and D1 merge.

Closes #1429
Part of #643

Model: MiniMax M2.7 (GMI Cloud via OpenRouter, jcode) — round 2 by Ox Alpha via Nous
Portal (jcode). Reviewer: tencent/hy3:free (adversarial rounds 1-2; round 1 rejected 5
CRITICAL / 7 MAJOR fabrications, round 2 APPROVED at 9e3871a after verifying each
finding fixed and re-auditing the symbols).
Unverified: the plan is not executed yet; its proofs become real in the C4 implementation.
# Conflicts:
#	packages/client-ts/src/kiota-lock.json
…ng 5)

develop's #1432 docs prune closed docs/superpowers; the C2 plan was the last
file there and its goal line pointed at a pruned spec path. Rename to the
records convention (YYYY-MM-DD-plan-topic), fix the self-reference, and
annotate the pruned Epic C spec pointer instead of linking a dead path.
… needs-reconnect (round 2, finding 1)

BlueskySessionProvider had zero specs. New BlueskySessionProviderSpec drives
every mapping through FakeBlueskyClient: success -> Opened(Did, Handle,
AccessJwt, PdsHost); Unprotect absent/tampered -> AccountFailure(no usable
stored credential); missing row -> AccountFailure(social account not found);
client AccountFailure -> AccountFailure; client Transient -> Transient with
NO stored-state change.

Substance behind the verdict's '401 flips needs-reconnect': the provider now
persists the flip itself (ExecuteUpdateAsync Status=NeedsReconnect,
LastError=sanitised cause, UpdatedAt) so Epic D consumers inherit
transparent failure causes without re-implementing policy. Spec asserts the
persisted row (fresh DbContext, AsNoTracking).
…s (round 2, finding 2)

Positional records synthesize a ToString() that prints every property, so
logging BlueskyCredentials or SocialSession leaked the app password / the
short-lived access JWT. Both records now override ToString() + PrintMembers
rendering the secret as [REDACTED]; SocialSession.AccessJwt is additionally
[property: JsonIgnore] so structured-log serialization cannot leak it either.
Seam signatures unchanged (D1 positional construction intact); direct
property access preserved for legitimate consumers.

SocialSessionSecretRedactionSpec asserts ToString(), interpolation,
PrintMembers (via reflection) and JSON never contain the secret; RED before
the override (JsonSerializer leaked the JWT), GREEN after.
… connect (round 2, finding 3)

The socialConnectPermitLimit factory parameter existed but no test drove
the policy end-to-end. New case: factory with permit limit 1, Acme tenant
admin session, POST /social-accounts/connect twice -> first 201 (fake
client), second 429 application/problem+json with Retry-After, matching
every other policy exhaustion case.
… 2 keys (round 2, finding 4)

The five social-account response-message keys landed EN+FR with no parity
guard anywhere. New vitest spec pins full EN<->FR key parity of the
namespace and names the five keys explicitly (non-empty values both sides);
dropping either side now fails here instead of surfacing a raw
translationKey in an operator toast.
radandevist added a commit that referenced this pull request Aug 25, 2026
docs(records): D2 publish-now implementation plan

Implementation plan for D2 ("publish now": create a Publication for a post on a
connected Bluesky account and deliver it through the D1 job handler), in the
writing-plans format under docs/records/2026-08-25-plan-d2-publish-now.md. Every
consumed symbol is named with the branch it lives on (develop, D1 = PR #1433, C2 =
PR #1439) with signatures copied via git show; the permission gate uses the exact
tenant.-prefixed wire key, pagination derives from CursorPaginatedQuery with its
validator cap, and the adversarial-mutation step targets a D2-owned spec that really
goes red. Executable once D1 and C2 merge.

Closes #1435
Part of #645

Model: Ox Alpha via Nous Portal (jcode), effort max. Reviewer: hy3 (Go) round 1,
tencent/hy3:free rounds 2-3 (adversarial; round 1 passed the full symbol audit and
found two design drifts, round 2 found three more against the real code, round 3
APPROVED_WITH_FOLLOW_UPS at edb2333). Follow-ups tracked in #1445.
Unverified: the plan is not executed yet; its proofs become real in the D2 implementation.
radandevist added a commit that referenced this pull request Aug 25, 2026
)

docs(records): C3 Integrations screen implementation plan

Implementation plan for C3 (the tenant Integrations screen: list connected social
accounts, connect / reconnect / disconnect, assign projects, failure causes shown as
they are), in the writing-plans format under
docs/records/2026-08-25-plan-c3-integrations-screen.md. Round 2 aligned every symbol,
wire shape (CursorPaginatedResult), test harness idiom, route, prop type and hook on
the real code of develop and the C2 branch (PR #1439), and states precisely which text
reaches the user for each failure class. Executable once C2 merges.

Closes #1437
Part of #642

Model: Ox Alpha via Nous Portal (jcode), effort max. Reviewer: hy3 (Go) round 1,
tencent/hy3:free round 2 (adversarial; round 1 found 4 MAJOR + 3 MEDIUM fabrications,
round 2 APPROVED_WITH_FOLLOW_UPS at f87f2cf). Follow-ups tracked in #1447.
Unverified: the plan is not executed yet; its proofs become real in the C3 implementation.
@radandevist
radandevist merged commit caa7e60 into develop Aug 25, 2026
28 checks passed
@radandevist
radandevist deleted the lane/wt-641 branch August 25, 2026 23:05
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.

C2 — Connect Bluesky account (app password)

1 participant