Skip to content

promote: dev -> main (platform batch, prod incident fixes, auth UX) - #56

Merged
JOY (JOY) merged 10 commits into
mainfrom
dev
Sep 22, 2026
Merged

JOY (JOY) merged 10 commits into
mainfrom
dev

Conversation

@JOY

Copy link
Copy Markdown

What kind of change does this PR introduce?

Release promotion: dev -> main. Merges everything validated on dev into main so the production image (ghcr.io/dos/crove-post:latest, built from main) picks it up. No new code authored in this PR - it is the dev branch as reviewed and merged PR-by-PR.

Why was this change needed?

The promotion ships: (1) the platform minimal batch (docs/ADR suite, upstream sync, eslint 9 green CI, vitest + e2e test tracks, prod compose corrections), (2) the prod incident fixes - the DOS billing owner guard (PR #53: only the org owner's DOS entitlement may write the org subscription, after a free-plan member login wiped an ULTIMATE subscription) and the auth UX fixes (PR #55: SSO auto-redirect + honest error page with loop-guarded retry), (3) the e2e verified DOS ID login flow.

Technical Details & Scope

Full dev history since the last promote. Highlights:

  • Billing: DosSharedBillingService.syncOrg owner guard (SUPERADMIN-role-only writes) + unit suite.
  • Auth: OauthProvider autoStart with a 10s bounce cap, AuthErrorState replacing the silent signup fallback.
  • Tooling: eslint 9/typescript-eslint 8 (ESLint workflow green), vitest frontend suite, Playwright smoke harness, CI step additions in build.yml.
  • Ops: scripts/docker-compose.prod.yaml corrections (dynamicconfig reality, healthcheck probe target, deployment notes) - already applied to the VM.
  • Docs: ADR-0001, fork-delta inventory, ops reconciliation doc, CLAUDE.md corrections.

Verification & Testing

Every constituent PR was CI-gated (build.yml: Postgres/Redis bootstrap suites, frontend vitest, full build; branding-guard) and reviewed. Auth login verified live against beta (e2e) and prod (post-incident verification script). Prod data integrity verified via SQL before/after the incident remediation.

QA

  1. CI green on this PR (build.yml + branding-guard + CodeQL)
  2. Merge, wait for build-containers.yml to publish ghcr.io/dos/crove-post:latest from main
  3. Deploy on the VM: docker compose pull crove-post && up -d; verify healthcheck healthy
  4. Verify login on post.crove.com (existing-user landing), the /auth auto-redirect, and that a non-owner test login no longer mutates org subscriptions (the fix(billing): only the org owner's DOS entitlement may write the org subscription #53 guard live)
  5. Verify the 3 orgs keep ULTIMATE / isLifetime=true subscriptions after login

Checklist:

  • My code follows the project's code style and architectural conventions.
  • Local verification done - all constituent PRs CI-gated and reviewed.
  • Branding guard - CI gate on this PR.
  • Tests - the dev branch carries the new vitest + guard suites.
  • Documentation has been updated (docs suite in docs: minimal batch groundwork - ADR-0001, fork-delta inventory, CLAUDE.md corrections #42).
  • No secrets or sensitive credentials are included in this PR.
  • I have filled in the QA / Verification section above with real steps to verify this change.

…d smoke

Verified live against beta with the test1@dos.me test account (provided by
JOY; same credentials on prod and beta):

- login via DOS ID works (in-tab or popup navigation both handled)
- first-login onboarding is handled: when the user has no organization the
  app shows a Company + Create Account screen; the test creates the 'E2E
  Test Workspace' organization (verified: lands on /launches)
- a fresh workspace then shows the DOS shared-billing plan gate (only Plus /
  Pro checkout, no free/skip path) with no connected channel, so the
  compose -> schedule -> calendar leg is gated: the test SKIPS with a clear
  reason instead of failing. Enabling the full leg needs either a DOS plan
  entitlement for the test account (DOS.Me admin action) or a free/skip path
  on the gate page (product decision).
test(e2e): working DOS ID login + onboarding handling in authenticated smoke
…subscription

Incident 2026-09-22: a free-plan member login (test1@dos.me, ADMIN in the
org) triggered the DOS shared-billing sync on the users endpoint and
clearDosSyncedSubscription - deleteMany({ organizationId }) - wiped the org's
ULTIMATE stripe subscription. Any free-plan member of any paid org could do
this on every page load.

- syncOrg now resolves the caller's membership role and only proceeds to
  clear/sync when the role is SUPERADMIN (the owner role this codebase
  assigns to org creators). Members get a read-only mapped view of their own
  DOS plan instead; the org subscription is untouched.
- tests/bootstrap-dos-sync-guard.spec.ts: 5 pure unit cases (owner free
  clears, owner plus syncs, member free/plus read-only, non-DOS user no
  write). Repository modules are stubbed with explicit jest.mock factories -
  their real prisma import graph cannot load in the CJS jest context.

Prod data was restored separately (subscription recreated, isLifetime
flipped back on the 3 orgs).
- generateAuthUrl redirects to api.dos.me/oauth/reddit/authorize with a
  per-env product label (crove-post-prod/beta, override REDDIT_BROKER_PRODUCT)
  and a >=128-bit state instead of Reddit directly with REDDIT_CLIENT_ID
- authenticate exchanges the broker's one-time delivery handle for the token
  bundle via POST /oauth/reddit/token-delivery/:handle (X-API-Key =
  DOS_ME_INTERNAL_API_KEY); the state/login Redis contract is unchanged
- REDDIT_CLIENT_ID/SECRET stay in the env: the broker does not cover the
  refresh grant, which 401 recovery for scheduled posts older than an hour
  depends on
- frontend continue page maps the callback's handle param to the connect
  body's code field
The reviewer verified the real repository modules do in fact load in this
CJS jest context (the file-type ESM claim was wrong); the stubs exist for
isolation so interaction assertions stay on the injected instances. State
that accurately.
fix(billing): only the org owner's DOS entitlement may write the org subscription
… silent signup fallback

Two commissioned auth UX fixes for the DOS ID single-provider flow:

1. Auto-redirect: when SSO is the only auth method (isGeneral + genericOauth),
   the /auth and /auth/login pages now start the DOS ID flow immediately
   (Redirecting to DOS ID... loader) instead of showing a page whose only
   content is one button. OauthProvider gains an autoStart prop: it fires
   gotoLogin on mount, falls back to the manual button if the link fetch
   fails, and clearing the error-page retry budget on a deliberately
   initiated flow.

2. Honest error state: a failed /oauth/:provider/exists exchange (state
   cookie mismatch, upstream token error) previously fell through to the
   signup form, so a broken sign-in looked like a fresh registration - the
   exact confusion reported on 2026-09-21 during the prod login outage.
   AuthErrorState now shows what happened (HTTP status, error message) with
   a loop-guarded retry: up to 2 automatic SSO restarts via sessionStorage
   budget, then a manual link so a persistent failure cannot ping-pong.

The i18n strings use the fork's t(key, english-default) pattern consistent
with the existing SSO strings on these pages.
feat(reddit): connect through the dos.me OAuth broker
Review round: an IdP error bounce can return to /auth WITHOUT a code, which
skips the exists-exchange error path entirely and re-fires the auto-start -
an uncontrolled /auth <-> IdP redirect storm is possible if the IdP ever
auto-returns. Cap it: OauthProvider's autoStart writes a sessionStorage
timestamp before navigating and refuses to auto-redirect again within 10s
(falls back to the manual button). Manual clicks are exempt.

Also per review: drop the dead useT in Register and the never-called
onRetry prop of AuthErrorState.
fix(auth): SSO auto-redirect + honest error page instead of the silent signup fallback
Comment on lines +113 to +125
const retry = useCallback(async () => {
try {
window.sessionStorage.setItem(DOS_OAUTH_RETRY_KEY, String(attempts + 1));
const response = await fetch('/auth/oauth/GENERIC');
if (response.ok) {
window.location.href = await response.text();
return;
}
} catch (e) {
console.error('Failed to restart the SSO flow:', e);
}
window.location.href = '/auth/login';
}, [attempts]);
console.error('Failed to restart the SSO flow:', e);
}
window.location.href = '/auth/login';
}, [attempts]);

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces several enhancements to the authentication and billing systems, including an auto-start feature for OAuth, a loop-guarded retry state for failed OAuth exchanges, integration with the dos.me Reddit OAuth broker, and a security guard that restricts organization subscription synchronization to organization owners (SUPERADMIN). Additionally, unit and E2E tests have been updated to cover these changes. The review feedback suggests optimizing the database query in DosSharedBillingService by adding a targeted method in OrganizationRepository to fetch only the specific organization and user role, rather than filtering all of a user's organizations in memory.

Comment on lines +54 to +56
const membership = await this.organizations
.getOrgsByUserId(user.id)
.then((orgs) => orgs.find((o) => o.id === organizationId));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Fetching all of a user's organizations and then filtering in memory to find their role in a specific organization can be inefficient. This could lead to performance issues for users who are members of a large number of organizations.

A more performant approach would be to add a dedicated method in OrganizationRepository to fetch only the specific organization and the user's role within it.

For example, a method like findUserRoleInOrg(userId: string, organizationId: string): Promise<{ role: Role } | null> could execute a more targeted database query. Here is an example of how that might be implemented in your repository:

// In OrganizationRepository
async findUserRoleInOrg(userId: string, organizationId: string) {
  const org = await this.prisma.organization.findUnique({
    where: { id: organizationId },
    select: {
      users: {
        where: { userId },
        select: { role: true },
      },
    },
  });
  return org?.users?.[0] || null;
}

This would significantly reduce the amount of data fetched from the database and improve the performance of this critical path.

@JOY
JOY (JOY) merged commit 3ca2790 into main Sep 22, 2026
22 of 23 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.

2 participants