feat(social): C2 Bluesky connect (#641) - #1439
Merged
Merged
Conversation
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.
…(uncommitted work rescued)
… 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.
…round 3, spec-drift gate)
# Conflicts: # apps/api/Modules/SocialAccounts/Services/ISocialSessionProvider.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/)IBlueskyClientHTTP adapter over BlueskycreateSessionwith 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.BlueskySessionProviderimplements the Epic-DISocialSessionProvider.OpenSessionAsyncseam exactly (identifier = stored handle, secret decrypted only in-flight).FakeBlueskyClientrecords every attempt and programs outcomes; fakes in every spec, never real network.Rate limiting (
Lib/RateLimiting/, env)SocialConnectpolicy (default 5 permits / 3600 s per session fingerprint), stricter than reads by design (spec §4). Wired throughAppEnvironment(SOCIAL_CONNECT_RATE_LIMIT_*),.env.example, settings record, limiter store, coverage validator.Domain service (
Services/SocialAccountService.cs)ProtectedCredentialserased to empty sentinel; soft delete untouched (history kept).CursorSortFieldHandler(created_at/updated_at) + optional project visibility filter. Project links are loaded beforeVisibleInruns — underAsNoTrackingan 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-accountsgroup: GET list (view, HeavySearchList), POST/connect(manage, SocialConnect), POST/{id}/reconnect(manage, SocialConnect), POST/{id}/disconnect(manage, default), PUT/{id}/projects(manage, default).AppProviderUnavailableHttpResult(+TypedProblems.ProviderUnavailable) — 503 reserved strictly for downstream provider outages.socialaccount.connected/reconnected/disconnected/projects.set; details carry handle/DID/project ids, never the app password.credentials-refused,provider-unreachable,social-account-not-found,social-account-disconnected-success,project-not-found) + regeneratedResponseKeys.g.cs.tenant.socialaccounts.view/manage/publish) landed earlier on this branch.Client
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).SocialAccountService.*Spec, exclusive ApiFixture per class, at-rest secrets verified through an independent DbContext)..dump/mutation-check.mdsibling 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..dump/mutation-check.md): dropping the tenant filter fails the build outright (IDE0060 — parameter unused); neutralizing it with|| trueturnsSocialAccountIsolationSpecred with tenant B successfully mutating A's account. Restored byte-exact (SHA-256 verified), suites green again.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; substitutedjust build-api+ targeted suites + fulljust test-apias 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 requiresSOCIAL_ACCOUNTS_MASTER_KEYalready 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/.BlueskySessionProviderspec (b85ec4ef7) — newBlueskySessionProviderSpecdrives every outcome throughFakeBlueskyClient: 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 freshAsNoTrackingcontext); transient → Transient with zero stored-state change. Proof: swapping the Transient/AccountFailure mapping → RED 2/5 (session-provider-red.log), restored → GREEN 5/5.4090ed980) —BlueskyCredentialsandSocialSessionoverrideToString()/PrintMembersrendering the app password / access JWT as[REDACTED];AccessJwtadditionally[property: JsonIgnore]so serializer-based logging cannot leak it. Positional ctor unchanged (D1 seam intact). NewSocialSessionSecretRedactionSpecasserts ToString(), interpolation, reflectionPrintMembersand JSON output never contain the secret value: RED 0/3 before the overrides, GREEN 3/3 after (redaction-red.log/-green.log).SocialConnectexhausted over HTTP (f6ad50979) — newComprehensiveRateLimitingSpeccase uses the previously unusedsocialConnectPermitLimit: 1factory parameter and drives two real POSTs to/social-accounts/connect: first 201, second 429application/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.6eb6ce61d) —packages/shared-tsvitest spec pins full EN↔FR key parity of theresponse-messagenamespace 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).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-driftREQUIRED gate was RED (committedkiota-lock.jsonstale vs committedopenapi.json).939340af1) —packages/client-ts/src/kiota-lock.jsonregenerated from the committedapps/api/openapi.jsonviajust build-api && just generate-client. ThedescriptionHashflips17ADE7D5… → 6DFA216E…, byte-for-byte the hash the reviewer derived independently. Determinism proof: a second cleanjust build-api && just generate-clientat the new tip leftgit statuscompletely empty (zero diff, lock file was the only changed artifact).863b51118) — merges origin/develop at2ef6872b0, 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 enrichedISocialSessionProvider.cskeeps D1's contract byte-identical — positional ctorSocialSession(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]onAccessJwtplusToString()/PrintMembersrendering 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'sUnimplementedSocialSessionProviderregisters throughTryAddScoped, so this lane's realAddScoped<ISocialSessionProvider, BlueskySessionProvider>()wins last-wins with no code change; the placeholder remains only as a fail-loud backstop.heavy.sh, canonical test env values from.env.example):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 freshpnpm --filter front build.pnpm lint: green.just build-api && just generate-clientat the merge tip, clean tree).863b51118) — polled to completion only (gh pr checks, every check terminal before reading results): all 28 checks pass, zero failures, including the previously-redspec-driftand itsopenapi-spec-drift-gatesummary, 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.