diff --git a/.llm/plans/completed/membership-rename/00-EXECUTION-ORDER.md b/.llm/plans/completed/membership-rename/00-EXECUTION-ORDER.md new file mode 100644 index 00000000..ad13fe60 --- /dev/null +++ b/.llm/plans/completed/membership-rename/00-EXECUTION-ORDER.md @@ -0,0 +1,45 @@ +# Execution order — membership rename + +Phases are **sequential**. Do not start the next phase until the previous phase is complete and verified. + +## Phase 1 — Schema (blocking) + +- [01-schema-migrations.md](01-schema-migrations.md) + +## Phase 2 — ORM entity and relations + +- [02-orm-entity-and-relations.md](02-orm-entity-and-relations.md) + +Depends on Phase 1 (table name in `@Entity`). + +## Phase 3 — ORM services + +- [03-orm-services.md](03-orm-services.md) + +Depends on Phase 2 (`UserMembership` entity and `User.membership`). + +## Phase 4 — API and management-api + +- [04-api-and-management-usages.md](04-api-and-management-usages.md) + +Depends on Phase 3 (`upsertMembership`, relation name). + +## Phase 5 — Seed and E2E tooling + +- [05-seed-and-e2e-tooling.md](05-seed-and-e2e-tooling.md) + +Depends on Phase 1 (`user_membership` table exists in schema). + +## After all phases + +Operator verification (cumulative): + +```bash +npm run build:packages +npm run lint +npm run test:unit +npm run test:e2e:api +make e2e_test_web_report_spec SPEC=e2e/auth-stale-cookies-redirect-login.spec.ts +``` + +For existing local Docker DB (table rename without full re-init), re-run `make local_db_init` or apply the one-off in [05-seed-and-e2e-tooling.md](05-seed-and-e2e-tooling.md). diff --git a/.llm/plans/completed/membership-rename/00-SUMMARY.md b/.llm/plans/completed/membership-rename/00-SUMMARY.md new file mode 100644 index 00000000..b3e871c9 --- /dev/null +++ b/.llm/plans/completed/membership-rename/00-SUMMARY.md @@ -0,0 +1,41 @@ +# Membership rename plan set + +Hard-break rename of `user_trust_settings` → `user_membership` and `UserTrustSettings` → `UserMembership` across schema, ORM, APIs, seeds, and E2E tooling. No fallbacks or legacy names. + +## Recorded decisions + +| Decision | Choice | +|----------|--------| +| Table / entity | `user_membership` / `UserMembership` (single 1:1 table, inline `membership_tier`) | +| Podverse mirror | Naming domain only (membership); **not** the two-table split (`account_membership` + `account_membership_status`) | +| Scope | Table, entity, relation, and code usages only | +| Out of scope | `packages/helpers/src/trust/` (entitlement capability keys + membership constants); management-web `trustTier*` i18n keys | +| Migration style | Edit original `CREATE TABLE` in `0003`; fold `0004` ALTER columns into `0003`; rename migration files; regenerate `0003a` baseline | + +## Plan files + +| File | Topic | +|------|--------| +| [01-schema-migrations.md](01-schema-migrations.md) | Linear SQL, kustomization, baseline regen | +| [02-orm-entity-and-relations.md](02-orm-entity-and-relations.md) | Entity, User relation, exports | +| [03-orm-services.md](03-orm-services.md) | UserService, renewal orchestrator, period extension | +| [04-api-and-management-usages.md](04-api-and-management-usages.md) | requireAuth, billing, management users | +| [05-seed-and-e2e-tooling.md](05-seed-and-e2e-tooling.md) | Local dev seed + E2E seed script | + +## Dependency map + +```mermaid +flowchart LR + schema[01_schema] --> ormEntity[02_orm_entity] + ormEntity --> ormServices[03_orm_services] + ormServices --> api[04_api_mgmt] + schema --> seed[05_seed] +``` + +## Integrates + +- Local dev login fix: `localdev` gets `user_membership` row (premium, long expiry) in [0008_seed_local_user.sql](../../../infra/k8s/base/db/source/bootstrap/0008_seed_local_user.sql). + +## Status + +Implemented on branch; operator must re-init local DB or run one-off insert for existing Docker volumes. diff --git a/.llm/plans/completed/membership-rename/01-schema-migrations.md b/.llm/plans/completed/membership-rename/01-schema-migrations.md new file mode 100644 index 00000000..bfe29555 --- /dev/null +++ b/.llm/plans/completed/membership-rename/01-schema-migrations.md @@ -0,0 +1,45 @@ +# 01 — Schema migrations + +## Scope + +Replace `user_trust_settings` with `user_membership` in the linear migration chain. Fold billing-renewal columns from `0004` into the `0003` `CREATE TABLE`. No ALTER-rename migration. + +## Steps + +1. **Replace** `infra/k8s/base/ops/source/database/linear-migrations/app/0003_user_trust_and_entitlement_overrides.sql` with `0003_user_membership.sql`: + - `CREATE TABLE user_membership` with all membership + renewal columns + - Constraints `chk_user_membership_*` + - Trigger `set_updated_at_user_membership` + - Backfill `INSERT INTO user_membership ... SELECT ... FROM "user"` + - Indexes `idx_user_membership_next_renewal_attempt_at`, `idx_user_membership_renewal_retry_backoff_until` + +2. **Replace** `0004_billing_catalog_and_trust_renewal.sql` with `0004_billing_catalog_and_membership_renewal.sql`: + - Keep billing catalog tables and seeds + - Remove `ALTER TABLE user_trust_settings` and trust-named indexes + +3. **Update** [infra/k8s/base/ops/kustomization.yaml](infra/k8s/base/ops/kustomization.yaml) ConfigMap file list for the two new filenames. + +4. **Regenerate baseline** (operator or agent with Nix): + +```bash +bash scripts/database/generate-linear-baseline.sh +``` + +Commit `infra/k8s/base/db/source/bootstrap/0003a_app_linear_baseline.sql.gz`. + +## Key files + +- `infra/k8s/base/ops/source/database/linear-migrations/app/0003_user_membership.sql` +- `infra/k8s/base/ops/source/database/linear-migrations/app/0004_billing_catalog_and_membership_renewal.sql` +- `infra/k8s/base/ops/kustomization.yaml` +- `infra/k8s/base/db/source/bootstrap/0003a_app_linear_baseline.sql.gz` + +## Verification + +```bash +bash scripts/database/validate-linear-migrations.sh +bash scripts/database/verify-linear-baseline.sh +gunzip -c infra/k8s/base/db/source/bootstrap/0003a_app_linear_baseline.sql.gz | rg user_membership +``` + +Expect `user_membership` in baseline; no `user_trust_settings`. diff --git a/.llm/plans/completed/membership-rename/02-orm-entity-and-relations.md b/.llm/plans/completed/membership-rename/02-orm-entity-and-relations.md new file mode 100644 index 00000000..2ee8253f --- /dev/null +++ b/.llm/plans/completed/membership-rename/02-orm-entity-and-relations.md @@ -0,0 +1,43 @@ +# 02 — ORM entity and relations + +## Scope + +Rename TypeORM entity and `User` relation from trust settings to membership. + +## Steps + +1. **Delete** `packages/orm/src/entities/UserTrustSettings.ts`. + +2. **Add** `packages/orm/src/entities/UserMembership.ts`: + - `@Entity('user_membership')` + - Class `UserMembership` + - `@OneToOne('User', (u) => u.membership)` + +3. **Update** [packages/orm/src/entities/User.ts](packages/orm/src/entities/User.ts): + - Import `UserMembership` + - Relation property `membership!: UserMembership` + +4. **Update** [packages/orm/src/types/UserWithRelations.ts](packages/orm/src/types/UserWithRelations.ts): + - `membership: UserMembership | null` + +5. **Update** [packages/orm/src/data-source.ts](packages/orm/src/data-source.ts) entity list. + +6. **Update** [packages/orm/src/index.ts](packages/orm/src/index.ts): + - Export `UserMembership` (not `UserTrustSettings`) + +## Key files + +- `packages/orm/src/entities/UserMembership.ts` +- `packages/orm/src/entities/User.ts` +- `packages/orm/src/types/UserWithRelations.ts` +- `packages/orm/src/data-source.ts` +- `packages/orm/src/index.ts` + +## Verification + +```bash +npm run build -w @metaboost/orm +rg "UserTrustSettings|trustSettings" packages/orm/src +``` + +Expect no matches. diff --git a/.llm/plans/completed/membership-rename/03-orm-services.md b/.llm/plans/completed/membership-rename/03-orm-services.md new file mode 100644 index 00000000..64ba9ab0 --- /dev/null +++ b/.llm/plans/completed/membership-rename/03-orm-services.md @@ -0,0 +1,36 @@ +# 03 — ORM services + +## Scope + +Update services that read/write membership rows and raw SQL against the table. + +## Steps + +1. **UserService** ([packages/orm/src/services/UserService.ts](packages/orm/src/services/UserService.ts)): + - `USER_RELATIONS`: `'membership'` + - Repository `UserMembership` + - Rename `upsertTrustSettings` → `upsertMembership` + +2. **BillingRenewalOrchestratorService** ([packages/orm/src/services/billingRenewalOrchestrator.ts](packages/orm/src/services/billingRenewalOrchestrator.ts)): + - `FROM user_membership` in raw SQL + - `getRepository(UserMembership)` + +3. **MembershipPeriodExtensionService** ([packages/orm/src/services/membershipPeriodExtension.ts](packages/orm/src/services/membershipPeriodExtension.ts)): + - `getRepository(UserMembership)` + - Local variable `membership` (not `trust`) + - Error messages reference "membership" + +## Key files + +- `packages/orm/src/services/UserService.ts` +- `packages/orm/src/services/billingRenewalOrchestrator.ts` +- `packages/orm/src/services/membershipPeriodExtension.ts` + +## Verification + +```bash +npm run build -w @metaboost/orm +rg "user_trust_settings|UserTrustSettings|trustSettings" packages/orm/src +``` + +Expect no matches. diff --git a/.llm/plans/completed/membership-rename/04-api-and-management-usages.md b/.llm/plans/completed/membership-rename/04-api-and-management-usages.md new file mode 100644 index 00000000..84265106 --- /dev/null +++ b/.llm/plans/completed/membership-rename/04-api-and-management-usages.md @@ -0,0 +1,42 @@ +# 04 — API and management-api usages + +## Scope + +Update auth middleware, billing read model, management user CRUD, and integration tests. + +## Steps + +1. **requireAuth** ([apps/api/src/middleware/requireAuth.ts](apps/api/src/middleware/requireAuth.ts)): + - `Reflect.get(value, 'membership')` for `membershipExpiresAt` + +2. **billingController** ([apps/api/src/controllers/billingController.ts](apps/api/src/controllers/billingController.ts)): + - `user.membership` + +3. **billingReadModelSerialization** ([apps/api/src/lib/billingReadModelSerialization.ts](apps/api/src/lib/billingReadModelSerialization.ts)): + - `params.user.membership` + - Rename `premiumBillingCadenceFromTrust` → `premiumBillingCadenceFromMembership` + +4. **management usersController** ([apps/management-api/src/controllers/usersController.ts](apps/management-api/src/controllers/usersController.ts)): + - `user.membership` in `userToJson` + - `leftJoinAndSelect('user.membership', 'membership')` + - `UserService.upsertMembership` + +5. **Integration test** ([apps/api/src/test/billing-renewal-orchestrator.test.ts](apps/api/src/test/billing-renewal-orchestrator.test.ts)): + - SQL `user_membership` + - `refreshed?.membership` + +## Key files + +- `apps/api/src/middleware/requireAuth.ts` +- `apps/api/src/controllers/billingController.ts` +- `apps/api/src/lib/billingReadModelSerialization.ts` +- `apps/management-api/src/controllers/usersController.ts` +- `apps/api/src/test/billing-renewal-orchestrator.test.ts` + +## Verification + +```bash +npm run build -w apps/api +npm run build -w apps/management-api +npm run test:e2e:api +``` diff --git a/.llm/plans/completed/membership-rename/05-seed-and-e2e-tooling.md b/.llm/plans/completed/membership-rename/05-seed-and-e2e-tooling.md new file mode 100644 index 00000000..56407cc1 --- /dev/null +++ b/.llm/plans/completed/membership-rename/05-seed-and-e2e-tooling.md @@ -0,0 +1,52 @@ +# 05 — Seed and E2E tooling + +## Scope + +Local dev bootstrap user and Playwright E2E seed use `user_membership`. Integrates deferred login fix for `localdev@example.com`. + +## Steps + +1. **Local Docker bootstrap** [infra/k8s/base/db/source/bootstrap/0008_seed_local_user.sql](infra/k8s/base/db/source/bootstrap/0008_seed_local_user.sql): + - After `user_bio`, insert premium membership with long expiry: + +```sql +WITH u AS (SELECT user_id AS id FROM user_credentials WHERE email = 'localdev@example.com') +INSERT INTO user_membership (user_id, membership_tier, membership_expires_at, auto_renew) +SELECT id, 'premium', NOW() + INTERVAL '100 years', false FROM u +ON CONFLICT (user_id) DO NOTHING; +``` + + - Keep `user_terms_acceptance` insert for terms gate. + +2. **E2E seed** [tools/web/seed-e2e.mjs](tools/web/seed-e2e.mjs): + - `INSERT INTO user_membership` (replace `user_trust_settings`) + +## Operator — existing local DB (before re-init) + +After code deploy, if the volume still has `user_trust_settings`, **re-init** is the clean path: + +```bash +make local_db_init +``` + +Or one-off on fresh schema (`user_membership` table): + +```bash +docker exec -i metaboost_local_postgres psql -U metaboost_app_owner -d metaboost_app <<'SQL' +WITH u AS (SELECT user_id AS id FROM user_credentials WHERE email = 'localdev@example.com') +INSERT INTO user_membership (user_id, membership_tier, membership_expires_at, auto_renew) +SELECT id, 'premium', NOW() + INTERVAL '100 years', false FROM u +ON CONFLICT (user_id) DO NOTHING; +SQL +``` + +Clear cookies for `http://localhost:4002` and log in with `localdev@example.com` / `Test!1Aa`. + +## Verification + +```bash +make e2e_seed +make e2e_test_web_report_spec SPEC=e2e/auth-stale-cookies-redirect-login.spec.ts +``` + +Open `.artifacts/e2e-reports/latest/web/index.html`. diff --git a/.llm/plans/completed/membership-rename/COPY-PASTA.md b/.llm/plans/completed/membership-rename/COPY-PASTA.md new file mode 100644 index 00000000..484de7d7 --- /dev/null +++ b/.llm/plans/completed/membership-rename/COPY-PASTA.md @@ -0,0 +1,53 @@ +# COPY-PASTA — membership rename + +Execute prompts in order. Mark `[x]` when complete. Move completed numbered files to `.llm/plans/completed/membership-rename/` per plan-execution-completion-tracking. + +--- + +## Phase 1 — Schema + +- [x] **Prompt 1** — Implement [01-schema-migrations.md](01-schema-migrations.md): replace `0003`/`0004` migration files, update kustomization, regenerate `0003a` baseline. + +--- + +## Phase 2 — ORM entity + +- [x] **Prompt 2** — Implement [02-orm-entity-and-relations.md](02-orm-entity-and-relations.md): `UserMembership` entity, `User.membership`, exports. + +--- + +## Phase 3 — ORM services + +- [x] **Prompt 3** — Implement [03-orm-services.md](03-orm-services.md): `UserService.upsertMembership`, renewal orchestrator, period extension. + +--- + +## Phase 4 — API and management-api + +- [x] **Prompt 4** — Implement [04-api-and-management-usages.md](04-api-and-management-usages.md): requireAuth, billing, management users, integration tests. + +--- + +## Phase 5 — Seed and E2E + +- [x] **Prompt 5** — Implement [05-seed-and-e2e-tooling.md](05-seed-and-e2e-tooling.md): `0008` local user membership seed, `tools/web/seed-e2e.mjs`. + +--- + +## Cumulative verification (operator) + +```bash +npm run build:packages +npm run lint +npm run test:unit +npm run test:e2e:api +make e2e_test_web_report_spec SPEC=e2e/auth-stale-cookies-redirect-login.spec.ts +``` + +For local dev login after rename: + +```bash +make local_db_init +``` + +Or apply the one-off in [05-seed-and-e2e-tooling.md](05-seed-and-e2e-tooling.md), then restart `npm run dev:all:watch`. diff --git a/apps/api/src/controllers/billingController.ts b/apps/api/src/controllers/billingController.ts index 1a438a41..c080fb2b 100644 --- a/apps/api/src/controllers/billingController.ts +++ b/apps/api/src/controllers/billingController.ts @@ -15,9 +15,9 @@ export async function membershipSummary(req: Request, res: Response): Promise { await appDataSourceReadWrite.query( ` - UPDATE user_trust_settings + UPDATE user_membership SET billing_cadence = $1, auto_renew_mode = $2, membership_expires_at = $3 @@ -65,8 +65,8 @@ describe('BillingRenewalOrchestratorService', () => { expect(summary.succeeded).toBeGreaterThanOrEqual(1); const refreshed = await UserService.findById(user.id); - expect(refreshed?.trustSettings?.membershipExpiresAt).not.toBeNull(); - expect(refreshed?.trustSettings?.membershipExpiresAt?.getTime() ?? 0).toBeGreaterThan( + expect(refreshed?.membership?.membershipExpiresAt).not.toBeNull(); + expect(refreshed?.membership?.membershipExpiresAt?.getTime() ?? 0).toBeGreaterThan( nearExpiry.getTime() ); @@ -101,7 +101,7 @@ describe('BillingRenewalOrchestratorService', () => { await appDataSourceReadWrite.query( ` - UPDATE user_trust_settings + UPDATE user_membership SET billing_cadence = $1, auto_renew_mode = $2, membership_expires_at = $3 @@ -117,7 +117,7 @@ describe('BillingRenewalOrchestratorService', () => { const eligible = (await appDataSourceReadWrite.query( ` SELECT user_id - FROM user_trust_settings + FROM user_membership WHERE user_id = $1 AND membership_tier = $2 AND auto_renew_mode = $3 @@ -149,7 +149,7 @@ describe('BillingRenewalOrchestratorService', () => { await appDataSourceReadWrite.query( ` - UPDATE user_trust_settings + UPDATE user_membership SET billing_cadence = $1, auto_renew_mode = $2, membership_expires_at = $3, @@ -175,10 +175,10 @@ describe('BillingRenewalOrchestratorService', () => { expect(summary.failed).toBeGreaterThanOrEqual(1); - const trustRows = (await appDataSourceReadWrite.query( + const membershipRows = (await appDataSourceReadWrite.query( ` SELECT renewal_retry_count, next_renewal_attempt_at, last_renewal_status - FROM user_trust_settings + FROM user_membership WHERE user_id = $1 `, [user.id] @@ -188,9 +188,9 @@ describe('BillingRenewalOrchestratorService', () => { last_renewal_status: string; }>; - expect(trustRows[0]?.renewal_retry_count).toBeGreaterThanOrEqual(1); - expect(trustRows[0]?.next_renewal_attempt_at).not.toBeNull(); - expect(trustRows[0]?.last_renewal_status).toBe('failed'); + expect(membershipRows[0]?.renewal_retry_count).toBeGreaterThanOrEqual(1); + expect(membershipRows[0]?.next_renewal_attempt_at).not.toBeNull(); + expect(membershipRows[0]?.last_renewal_status).toBe('failed'); const failEvents = (await appDataSourceReadWrite.query( ` diff --git a/apps/management-api/src/controllers/usersController.ts b/apps/management-api/src/controllers/usersController.ts index bd44ea99..9909c3dd 100644 --- a/apps/management-api/src/controllers/usersController.ts +++ b/apps/management-api/src/controllers/usersController.ts @@ -49,9 +49,9 @@ function userToJson(user: UserWithRelations): { email: user.credentials.email ?? null, username: user.credentials.username ?? null, displayName: user.bio?.displayName ?? null, - membershipTier: membershipTierFromStoredValue(user.trustSettings?.membershipTier), - membershipExpiresAt: user.trustSettings?.membershipExpiresAt?.toISOString() ?? null, - autoRenew: user.trustSettings?.autoRenew ?? false, + membershipTier: membershipTierFromStoredValue(user.membership?.membershipTier), + membershipExpiresAt: user.membership?.membershipExpiresAt?.toISOString() ?? null, + autoRenew: user.membership?.autoRenew ?? false, }; } @@ -100,7 +100,7 @@ export async function listUsers(req: Request, res: Response): Promise { .createQueryBuilder('user') .leftJoinAndSelect('user.credentials', 'credentials') .leftJoinAndSelect('user.bio', 'bio') - .leftJoinAndSelect('user.trustSettings', 'trustSettings'); + .leftJoinAndSelect('user.membership', 'membership'); if (sortBy === 'email') { qb.orderBy('credentials.email', sortOrder); @@ -306,7 +306,7 @@ export async function updateUser(req: Request, res: Response): Promise { body.membershipExpiresAt !== undefined || body.autoRenew !== undefined ) { - await UserService.upsertTrustSettings({ + await UserService.upsertMembership({ userId: id, membershipTier: body.membershipTier !== undefined diff --git a/apps/management-web/src/proxy.ts b/apps/management-web/src/proxy.ts index fb827a1b..c6ee3d17 100644 --- a/apps/management-web/src/proxy.ts +++ b/apps/management-web/src/proxy.ts @@ -3,6 +3,8 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { getServerManagementApiBaseUrl } from './config/env'; +import { hasRuntimeConfig, setRuntimeConfig } from './config/runtime-config-store'; +import { fetchManagementWebRuntimeConfigFromSidecar } from './config/runtime-config.server'; import { parseManagementMeEnvelope, type ManagementSessionUser, @@ -36,6 +38,22 @@ function appendClearSessionCookies(res: NextResponse): void { res.headers.append('Set-Cookie', `${REFRESH_COOKIE_NAME}=; ${opts}`); } +async function ensureRuntimeConfigHydrated(): Promise { + if (hasRuntimeConfig()) { + return; + } + const runtimeConfigUrl = process.env.RUNTIME_CONFIG_URL?.trim(); + if (runtimeConfigUrl === undefined || runtimeConfigUrl === '') { + return; + } + try { + const runtimeConfig = await fetchManagementWebRuntimeConfigFromSidecar(); + setRuntimeConfig(runtimeConfig); + } catch { + // Fall through to process.env fallback in getRuntimeConfig(). + } +} + async function trySessionRestore(request: NextRequest): Promise<{ response: NextResponse; hasRestoredSession: boolean; @@ -83,18 +101,22 @@ async function trySessionRestore(request: NextRequest): Promise<{ } catch { // If me JSON cannot be parsed, continue with normal session handling. } + const res = nextWithoutInboundAuthUser(request); + appendClearSessionCookies(res); return { - response: nextWithoutInboundAuthUser(request), + response: res, hasRestoredSession: false, - sessionInvalidated: false, + sessionInvalidated: true, authUser: null, }; } if (meRes.status !== 401) { + const res = nextWithoutInboundAuthUser(request); + appendClearSessionCookies(res); return { - response: nextWithoutInboundAuthUser(request), + response: res, hasRestoredSession: false, - sessionInvalidated: false, + sessionInvalidated: true, authUser: null, }; } @@ -143,9 +165,11 @@ export async function proxy(request: NextRequest) { return nextWithoutInboundAuthUser(request); } - const { response, hasRestoredSession, sessionInvalidated } = await trySessionRestore(request); - const hasSession = - (request.cookies.has(SESSION_COOKIE_NAME) || hasRestoredSession) && !sessionInvalidated; + await ensureRuntimeConfigHydrated(); + + const { response, hasRestoredSession, sessionInvalidated, authUser } = + await trySessionRestore(request); + const hasSession = (authUser !== null || hasRestoredSession) && !sessionInvalidated; const isPublic = PUBLIC_PATHS.includes(pathname); // Protected route without validated session -> redirect to login diff --git a/apps/web/e2e/auth-stale-cookies-redirect-login.spec.ts b/apps/web/e2e/auth-stale-cookies-redirect-login.spec.ts new file mode 100644 index 00000000..494fed61 --- /dev/null +++ b/apps/web/e2e/auth-stale-cookies-redirect-login.spec.ts @@ -0,0 +1,59 @@ +import { expect, test } from '@playwright/test'; + +import { getE2EApiV1BaseUrl } from './helpers/apiBase'; +import { capturePageLoad } from './helpers/stepScreenshots'; + +test.describe('Web stale session cookies', () => { + test('When bogus session cookies are sent, protected routes redirect to login and login does not bounce to dashboard.', async ({ + page, + context, + baseURL, + }, testInfo) => { + const origin = baseURL ?? 'http://localhost:4012'; + const { hostname } = new URL(origin); + await context.addCookies([ + { + name: 'api_session', + value: 'bogus-access-token-not-valid', + domain: hostname, + path: '/', + httpOnly: true, + sameSite: 'Lax', + }, + { + name: 'api_refresh', + value: 'bogus-refresh-token-not-valid', + domain: hostname, + path: '/', + httpOnly: true, + sameSite: 'Lax', + }, + ]); + + await page.goto('/dashboard'); + await expect.poll(() => new URL(page.url()).pathname).toBe('/login'); + await capturePageLoad( + page, + testInfo, + 'User with invalid session cookies lands on login after attempting the dashboard.' + ); + await expect(page.getByRole('textbox', { name: /email/i })).toBeVisible(); + + await page.goto('/login'); + await expect.poll(() => new URL(page.url()).pathname).toBe('/login'); + await capturePageLoad( + page, + testInfo, + 'Login stays on login when session cookies are invalid and not treated as authenticated.' + ); + await expect(page.getByRole('textbox', { name: /email/i })).toBeVisible(); + }); + + test('Unauthenticated API /auth/me returns 401 (E2E uses direct API origin, not web /v1 rewrite).', async ({ + request, + }) => { + const response = await request.get(`${getE2EApiV1BaseUrl()}/auth/me`); + expect(response.status()).toBe(401); + expect(response.url()).toContain('/v1/auth/me'); + }); +}); diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index e0e37193..793ed54c 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -9,6 +9,19 @@ const withBundleAnalyzer = bundleAnalyzer({ const nextConfig = { transpilePackages: ['@metaboost/ui'], + async rewrites() { + if (process.env.NODE_ENV === 'production') { + return []; + } + const apiOrigin = process.env.WEB_DEV_API_PROXY_TARGET?.trim() || 'http://localhost:4000'; + const base = apiOrigin.replace(/\/$/, ''); + return [ + { + source: '/v1/:path*', + destination: `${base}/v1/:path*`, + }, + ]; + }, async headers() { if (process.env.NODE_ENV !== 'production') { return [ diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx index 6c4aa874..1ade5cfa 100644 --- a/apps/web/src/app/(auth)/login/page.tsx +++ b/apps/web/src/app/(auth)/login/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useTranslations } from 'next-intl'; -import { useRouter, useSearchParams } from 'next/navigation'; +import { useSearchParams } from 'next/navigation'; import { useState } from 'react'; import { @@ -22,7 +22,6 @@ export default function LoginPage() { const tErrors = useTranslations('errors'); const tAuth = useTranslations('auth'); const { login } = useAuth(); - const router = useRouter(); const searchParams = useSearchParams(); const returnUrl = searchParams.get('returnUrl'); const showCheckEmailMessage = isTruthyQueryFlag(searchParams.get('checkEmail')); @@ -44,11 +43,14 @@ export default function LoginPage() { const result = await login(email, password); setLoading(false); if (result.ok) { - const target = + let target = returnUrl !== null && isSafeLoginReturnUrl(returnUrl, [ROUTES.LOGIN, ROUTES.SIGNUP]) ? returnUrl : ROUTES.DASHBOARD; - router.push(target); + if (result.mustAcceptTermsNow) { + target = ROUTES.TERMS_REQUIRED; + } + window.location.assign(target); } else if (result.rateLimit !== undefined) { setRateLimitRetrySeconds(result.rateLimit.retryAfterSeconds); setShowRateLimitModal(true); diff --git a/apps/web/src/context/AuthContext.tsx b/apps/web/src/context/AuthContext.tsx index 1029ba86..2c00e36b 100644 --- a/apps/web/src/context/AuthContext.tsx +++ b/apps/web/src/context/AuthContext.tsx @@ -62,7 +62,8 @@ export type AuthContextValue = { email: string, password: string ) => Promise< - { ok: true } | { ok: false; message: string; rateLimit?: { retryAfterSeconds: number } } + | { ok: true; mustAcceptTermsNow: boolean } + | { ok: false; message: string; rateLimit?: { retryAfterSeconds: number } } >; logout: () => Promise; setSession: (user: AuthUser) => void; @@ -182,7 +183,8 @@ export function AuthProvider({ children, initialUser }: AuthProviderProps) { email: string, password: string ): Promise< - { ok: true } | { ok: false; message: string; rateLimit?: { retryAfterSeconds: number } } + | { ok: true; mustAcceptTermsNow: boolean } + | { ok: false; message: string; rateLimit?: { retryAfterSeconds: number } } > => { const baseUrl = getApiBaseUrl(); const res = await webAuth.login(baseUrl, email, password); @@ -203,7 +205,7 @@ export function AuthProvider({ children, initialUser }: AuthProviderProps) { if (parsed !== null) { setUser(mapAuthPayloadToUser(parsed)); } - return { ok: true }; + return { ok: true, mustAcceptTermsNow: parsed?.mustAcceptTermsNow === true }; }, [] ); diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index 4e701dce..d500c6e3 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -5,8 +5,29 @@ function getApiVersionPath(): string { return ver && ver.startsWith('/') ? ver : '/v1'; } +function resolvePublicApiOrigin(configuredPublicBaseUrl: string): string { + const trimmed = configuredPublicBaseUrl.replace(/\/$/, ''); + if (trimmed === '') { + if (typeof window !== 'undefined') { + return window.location.origin; + } + return ''; + } + if (typeof window !== 'undefined' && process.env.NODE_ENV !== 'production') { + try { + const configured = new URL(trimmed); + if (configured.origin !== window.location.origin) { + return window.location.origin; + } + } catch { + // Fall through to configured URL when parsing fails. + } + } + return trimmed; +} + export function getApiBaseUrl(): string { - const base = getRuntimeConfig().env.NEXT_PUBLIC_API_PUBLIC_BASE_URL ?? ''; - const trimmed = base.replace(/\/$/, ''); - return trimmed + getApiVersionPath(); + const configured = getRuntimeConfig().env.NEXT_PUBLIC_API_PUBLIC_BASE_URL ?? ''; + const origin = resolvePublicApiOrigin(configured); + return origin.replace(/\/$/, '') + getApiVersionPath(); } diff --git a/apps/web/src/proxy.ts b/apps/web/src/proxy.ts index 5ab9f69f..03333f58 100644 --- a/apps/web/src/proxy.ts +++ b/apps/web/src/proxy.ts @@ -5,6 +5,8 @@ import { NextResponse } from 'next/server'; import { isSafeRelativeAppPath } from '@metaboost/helpers'; import { getApiVersionPath, getAccountSignupMode, getServerApiBaseUrl } from './config/env'; +import { hasRuntimeConfig, setRuntimeConfig } from './config/runtime-config-store'; +import { fetchWebRuntimeConfigFromSidecar } from './config/runtime-config.server'; import { parseAuthEnvelope, type AuthUserPayload } from './lib/auth-user'; import { getWebAccountSignupModeCapabilities } from './lib/authMode'; import { isPublicPath, loginRoute, ROUTES } from './lib/routes'; @@ -36,6 +38,27 @@ function appendClearSessionCookies(res: NextResponse): void { res.headers.append('Set-Cookie', `${REFRESH_COOKIE_NAME}=; ${opts}`); } +async function ensureRuntimeConfigHydrated(): Promise { + if (hasRuntimeConfig()) { + return; + } + const runtimeConfigUrl = process.env.RUNTIME_CONFIG_URL?.trim(); + if (runtimeConfigUrl === undefined || runtimeConfigUrl === '') { + return; + } + try { + const runtimeConfig = await fetchWebRuntimeConfigFromSidecar(); + setRuntimeConfig(runtimeConfig); + } catch { + // Fall through to process.env fallback in getRuntimeConfig(). + } +} + +function isProxiedApiPath(pathname: string): boolean { + const versionPath = getApiVersionPath(); + return pathname === versionPath || pathname.startsWith(`${versionPath}/`); +} + async function trySessionRestore(request: NextRequest): Promise<{ response: NextResponse; hasRestoredSession: boolean; @@ -84,18 +107,22 @@ async function trySessionRestore(request: NextRequest): Promise<{ } catch { // If me JSON cannot be parsed, continue with normal session handling. } + const res = nextWithoutInboundAuthUser(request); + appendClearSessionCookies(res); return { - response: nextWithoutInboundAuthUser(request), + response: res, hasRestoredSession: false, - sessionInvalidated: false, + sessionInvalidated: true, authUser: null, }; } if (meRes.status !== 401) { + const res = nextWithoutInboundAuthUser(request); + appendClearSessionCookies(res); return { - response: nextWithoutInboundAuthUser(request), + response: res, hasRestoredSession: false, - sessionInvalidated: false, + sessionInvalidated: true, authUser: null, }; } @@ -144,10 +171,16 @@ export async function proxy(request: NextRequest) { return nextWithoutInboundAuthUser(request); } + await ensureRuntimeConfigHydrated(); + + // Dev same-origin API proxy (next.config rewrites /v1/* → API); not an app route. + if (isProxiedApiPath(pathname)) { + return nextWithoutInboundAuthUser(request); + } + const { response, hasRestoredSession, sessionInvalidated, authUser } = await trySessionRestore(request); - const hasSession = - (request.cookies.has(SESSION_COOKIE_NAME) || hasRestoredSession) && !sessionInvalidated; + const hasSession = (authUser !== null || hasRestoredSession) && !sessionInvalidated; const isPublic = isPublicPath(pathname); const needsLatestTermsAcceptance = hasSession && authUser !== null && authUser.mustAcceptTermsNow === true; @@ -234,5 +267,5 @@ export async function proxy(request: NextRequest) { } export const config = { - matcher: ['/((?!_next/static|_next/image|favicon.ico|icon.svg).*)'], + matcher: ['/((?!_next/static|_next/image|favicon.ico|icon.svg|v1/).*)'], }; diff --git a/docs/QUICK-START.md b/docs/QUICK-START.md index 6dc55408..aa70f7a6 100644 --- a/docs/QUICK-START.md +++ b/docs/QUICK-START.md @@ -42,6 +42,21 @@ npm run dev:all - **Web:** http://localhost:4002 - **Web runtime-config sidecar:** http://localhost:4001 +### Local dev login + +After **`make local_db_init`**, the main web app has a seeded account: + +- **Email:** `localdev@example.com` +- **Password:** `Test!1Aa` + +Management-web requires a superuser from **`make local_management_superuser_create`** +(default username **`superuser`**, password **`Test!1Aa`**). + +If the browser loops between `/login` and `/dashboard` or logs **SecurityError** on history +API calls, clear site cookies for `http://localhost:4002` (remove `api_session` and +`api_refresh`) and use **`localhost`** consistently (not `127.0.0.1`). See +[INFRA-DOCKER-LOCAL.md](/infra/docker/local/INFRA-DOCKER-LOCAL.md) for infra details. + `make local_env_setup` seeds `apps/web/sidecar/.env` and `apps/management-web/sidecar/.env` from canonical template defaults plus overrides; `infra/config/local/*-sidecar.env` remains Docker Compose-only. See [LOCAL-ENV-OVERRIDES.md](development/LOCAL-ENV-OVERRIDES.md). diff --git a/infra/docker/local/INFRA-DOCKER-LOCAL.md b/infra/docker/local/INFRA-DOCKER-LOCAL.md index 4292ae7b..f170a460 100644 --- a/infra/docker/local/INFRA-DOCKER-LOCAL.md +++ b/infra/docker/local/INFRA-DOCKER-LOCAL.md @@ -32,7 +32,13 @@ To start only Postgres or Valkey (no management DB): - `docker compose -f infra/docker/local/docker-compose.yml --project-directory . up valkey` Default terms rows are created when **api** / **management-api** first start if `terms_version` is empty (not by init SQL). -The local-only dev account (**localdev@example.com** / **Test!1Aa**) is inserted by **`make local_db_init`** (seed file mounted at **`/opt/database/seed-scripts/local-dev-account.sql`**), not during initdb. +The local-only dev account (**localdev@example.com** / **Test!1Aa**) is inserted by **`make local_db_init`** (seed file mounted at **`/opt/database/seed-scripts/local-dev-account.sql`**), not during initdb. The seed includes terms acceptance for the bootstrap current version so host dev login can reach the dashboard without the terms gate. + +If the browser loops between `/login` and `/dashboard` after host dev (`npm run dev:all`), clear +`api_session` and `api_refresh` cookies for `http://localhost:4002` and use **`localhost`** +(not `127.0.0.1`) so cookie domain and CORS match local env. Management-web superuser: +**`make local_management_superuser_create`** (default **`superuser`** / **`Test!1Aa`**). + API/ORM use `DB_HOST=postgres` and `KEYVALDB_HOST=valkey` when running in Docker (Compose service hostname `valkey`). Kubernetes bases use cluster DNS `metaboost-db` / `metaboost-keyvaldb` for Postgres and Valkey Services. If Postgres previously failed during init (e.g. ordering bug) or you need a clean data directory, remove the volume and retry: **`make local_down_volumes`** or **`docker volume rm metaboost_postgres_data`**, then **`make local_infra_up`** and **`make local_db_init`**. diff --git a/infra/k8s/base/db/source/bootstrap/0003a_app_linear_baseline.sql.gz b/infra/k8s/base/db/source/bootstrap/0003a_app_linear_baseline.sql.gz index e83eea73..61bf4819 100644 Binary files a/infra/k8s/base/db/source/bootstrap/0003a_app_linear_baseline.sql.gz and b/infra/k8s/base/db/source/bootstrap/0003a_app_linear_baseline.sql.gz differ diff --git a/infra/k8s/base/db/source/bootstrap/0008_seed_local_user.sql b/infra/k8s/base/db/source/bootstrap/0008_seed_local_user.sql index 3c580700..5dc4727f 100644 --- a/infra/k8s/base/db/source/bootstrap/0008_seed_local_user.sql +++ b/infra/k8s/base/db/source/bootstrap/0008_seed_local_user.sql @@ -2,6 +2,8 @@ -- Default terms_version rows are not inserted here; api/management-api create them on first startup when the table is empty. -- Email: localdev@example.com Username: localdev Password: Test!1Aa -- email_verified_at set to NOW() so the user can log in immediately without verifying email. +-- Terms acceptance for the bootstrap current version so local dev login reaches the dashboard. +-- Premium membership (long expiry) so /auth/me passes membership gate. -- Hash generated with bcrypt 10 rounds; safe to commit. WITH u AS ( @@ -16,3 +18,14 @@ FROM u; WITH u AS (SELECT user_id AS id FROM user_credentials WHERE email = 'localdev@example.com') INSERT INTO user_bio (user_id, display_name) SELECT id, NULL FROM u; + +WITH u AS (SELECT user_id AS id FROM user_credentials WHERE email = 'localdev@example.com') +INSERT INTO user_membership (user_id, membership_tier, membership_expires_at, auto_renew) +SELECT id, 'premium', NOW() + INTERVAL '100 years', false FROM u +ON CONFLICT (user_id) DO NOTHING; + +WITH u AS (SELECT user_id AS id FROM user_credentials WHERE email = 'localdev@example.com') +INSERT INTO user_terms_acceptance (user_id, terms_version_id, accepted_at, acceptance_source) +SELECT id, 'a0000000-0000-4000-8000-000000000001'::uuid, NOW(), 'local-dev-seed' +FROM u +ON CONFLICT (user_id, terms_version_id) DO NOTHING; diff --git a/infra/k8s/base/ops/kustomization.yaml b/infra/k8s/base/ops/kustomization.yaml index 215de996..943fd3f8 100644 --- a/infra/k8s/base/ops/kustomization.yaml +++ b/infra/k8s/base/ops/kustomization.yaml @@ -24,8 +24,8 @@ configMapGenerator: files: - source/database/linear-migrations/app/0001_app_schema.sql - source/database/linear-migrations/app/0002_terms_default_seed.sql - - source/database/linear-migrations/app/0003_user_trust_and_entitlement_overrides.sql - - source/database/linear-migrations/app/0004_billing_catalog_and_trust_renewal.sql + - source/database/linear-migrations/app/0003_user_membership.sql + - source/database/linear-migrations/app/0004_billing_catalog_and_membership_renewal.sql - source/database/linear-migrations/app/0005_billing_domain_events.sql - name: metaboost-ops-management-migrations files: diff --git a/infra/k8s/base/ops/source/database/linear-migrations/app/0003_user_membership.sql b/infra/k8s/base/ops/source/database/linear-migrations/app/0003_user_membership.sql new file mode 100644 index 00000000..f563f528 --- /dev/null +++ b/infra/k8s/base/ops/source/database/linear-migrations/app/0003_user_membership.sql @@ -0,0 +1,45 @@ +-- Forward-only: per-user membership lifecycle (tier, expiry, renewal automation). +-- Schema-only phase; runtime gating is enforced in API middleware and services. + +CREATE TABLE user_membership ( + user_id UUID PRIMARY KEY REFERENCES "user"(id) ON DELETE CASCADE, + membership_tier VARCHAR(32) NOT NULL DEFAULT 'trial', + membership_expires_at TIMESTAMP NULL, + auto_renew BOOLEAN NOT NULL DEFAULT false, + billing_cadence TEXT CHECK ( + billing_cadence IS NULL OR billing_cadence IN ('monthly', 'annual') + ), + auto_renew_mode TEXT NOT NULL DEFAULT 'off' CHECK (auto_renew_mode IN ('off', 'on')), + next_renewal_attempt_at TIMESTAMP, + last_renewal_attempt_at TIMESTAMP, + last_renewal_status TEXT NOT NULL DEFAULT 'none' CHECK ( + last_renewal_status IN ('none', 'succeeded', 'failed') + ), + last_extension_idempotency_key VARCHAR(128), + last_renewal_idempotency_key VARCHAR(128), + renewal_retry_count INTEGER NOT NULL DEFAULT 0, + renewal_retry_backoff_until TIMESTAMP, + created_at server_time_with_default NOT NULL, + updated_at server_time_with_default NOT NULL, + CONSTRAINT chk_user_membership_tier + CHECK (membership_tier IN ('trial', 'premium')), + CONSTRAINT chk_user_membership_expires_after_epoch + CHECK (membership_expires_at IS NULL OR membership_expires_at > TIMESTAMP '1970-01-01 00:00:00') +); + +CREATE TRIGGER set_updated_at_user_membership + BEFORE UPDATE ON user_membership + FOR EACH ROW + EXECUTE FUNCTION set_updated_at_field(); + +INSERT INTO user_membership (user_id, membership_tier, auto_renew, auto_renew_mode) +SELECT u.id, 'trial', false, 'off' +FROM "user" AS u; + +CREATE INDEX idx_user_membership_next_renewal_attempt_at + ON user_membership(next_renewal_attempt_at) + WHERE next_renewal_attempt_at IS NOT NULL; + +CREATE INDEX idx_user_membership_renewal_retry_backoff_until + ON user_membership(renewal_retry_backoff_until) + WHERE renewal_retry_backoff_until IS NOT NULL; diff --git a/infra/k8s/base/ops/source/database/linear-migrations/app/0003_user_trust_and_entitlement_overrides.sql b/infra/k8s/base/ops/source/database/linear-migrations/app/0003_user_trust_and_entitlement_overrides.sql deleted file mode 100644 index 90d44c7f..00000000 --- a/infra/k8s/base/ops/source/database/linear-migrations/app/0003_user_trust_and_entitlement_overrides.sql +++ /dev/null @@ -1,25 +0,0 @@ --- Forward-only: add membership lifecycle and trust foundation. --- Schema-only phase; no runtime gating behavior is enabled by this migration. --- Greenfield: omit legacy trust_tier_id (removed in earlier chains as a follow-up migration). - -CREATE TABLE user_trust_settings ( - user_id UUID PRIMARY KEY REFERENCES "user"(id) ON DELETE CASCADE, - membership_tier VARCHAR(32) NOT NULL DEFAULT 'trial', - membership_expires_at TIMESTAMP NULL, - auto_renew BOOLEAN NOT NULL DEFAULT false, - created_at server_time_with_default NOT NULL, - updated_at server_time_with_default NOT NULL, - CONSTRAINT chk_user_trust_settings_membership_tier - CHECK (membership_tier IN ('trial', 'premium')), - CONSTRAINT chk_user_trust_settings_membership_expires_after_epoch - CHECK (membership_expires_at IS NULL OR membership_expires_at > TIMESTAMP '1970-01-01 00:00:00') -); - -CREATE TRIGGER set_updated_at_user_trust_settings - BEFORE UPDATE ON user_trust_settings - FOR EACH ROW - EXECUTE FUNCTION set_updated_at_field(); - -INSERT INTO user_trust_settings (user_id, membership_tier, auto_renew) -SELECT u.id, 'trial', false -FROM "user" AS u; diff --git a/infra/k8s/base/ops/source/database/linear-migrations/app/0004_billing_catalog_and_trust_renewal.sql b/infra/k8s/base/ops/source/database/linear-migrations/app/0004_billing_catalog_and_membership_renewal.sql similarity index 76% rename from infra/k8s/base/ops/source/database/linear-migrations/app/0004_billing_catalog_and_trust_renewal.sql rename to infra/k8s/base/ops/source/database/linear-migrations/app/0004_billing_catalog_and_membership_renewal.sql index 047283ae..dd44bbde 100644 --- a/infra/k8s/base/ops/source/database/linear-migrations/app/0004_billing_catalog_and_trust_renewal.sql +++ b/infra/k8s/base/ops/source/database/linear-migrations/app/0004_billing_catalog_and_membership_renewal.sql @@ -1,4 +1,4 @@ --- Forward-only: billing product/price catalog, membership trial settings, renewal metadata on trust settings. +-- Forward-only: billing product/price catalog, membership trial settings. CREATE TABLE billing_product ( id SERIAL PRIMARY KEY, @@ -110,29 +110,3 @@ INSERT INTO product_membership_settings ( updated_at ) VALUES (1, 2678400, TIMESTAMP '2000-01-01 00:00:00', TIMESTAMP '2000-01-01 00:00:00'); - -ALTER TABLE user_trust_settings - ADD COLUMN billing_cadence TEXT CHECK ( - billing_cadence IS NULL OR billing_cadence IN ('monthly', 'annual') - ), - ADD COLUMN auto_renew_mode TEXT NOT NULL DEFAULT 'off' CHECK (auto_renew_mode IN ('off', 'on')), - ADD COLUMN next_renewal_attempt_at TIMESTAMP, - ADD COLUMN last_renewal_attempt_at TIMESTAMP, - ADD COLUMN last_renewal_status TEXT NOT NULL DEFAULT 'none' CHECK ( - last_renewal_status IN ('none', 'succeeded', 'failed') - ), - ADD COLUMN last_extension_idempotency_key VARCHAR(128), - ADD COLUMN last_renewal_idempotency_key VARCHAR(128), - ADD COLUMN renewal_retry_count INTEGER NOT NULL DEFAULT 0, - ADD COLUMN renewal_retry_backoff_until TIMESTAMP; - -UPDATE user_trust_settings -SET auto_renew_mode = CASE WHEN auto_renew THEN 'on' ELSE 'off' END; - -CREATE INDEX idx_user_trust_settings_next_renewal_attempt_at - ON user_trust_settings(next_renewal_attempt_at) - WHERE next_renewal_attempt_at IS NOT NULL; - -CREATE INDEX idx_user_trust_settings_renewal_retry_backoff_until - ON user_trust_settings(renewal_retry_backoff_until) - WHERE renewal_retry_backoff_until IS NOT NULL; diff --git a/packages/orm/src/data-source.ts b/packages/orm/src/data-source.ts index 1e333146..9a2ff344 100644 --- a/packages/orm/src/data-source.ts +++ b/packages/orm/src/data-source.ts @@ -30,8 +30,8 @@ import { TermsVersion, TermsVersionContent } from './entities/TermsVersion.js'; import { User } from './entities/User.js'; import { UserBio } from './entities/UserBio.js'; import { UserCredentials } from './entities/UserCredentials.js'; +import { UserMembership } from './entities/UserMembership.js'; import { UserTermsAcceptance } from './entities/UserTermsAcceptance.js'; -import { UserTrustSettings } from './entities/UserTrustSettings.js'; import { UserWebPushSubscription } from './entities/UserWebPushSubscription.js'; import { VerificationToken } from './entities/VerificationToken.js'; @@ -39,7 +39,7 @@ const ENTITIES = [ User, UserCredentials, UserBio, - UserTrustSettings, + UserMembership, BillingProduct, BillingPrice, BillingDomainEvent, diff --git a/packages/orm/src/entities/User.ts b/packages/orm/src/entities/User.ts index 46377819..dc913cb1 100644 --- a/packages/orm/src/entities/User.ts +++ b/packages/orm/src/entities/User.ts @@ -11,7 +11,7 @@ import { NANO_ID_V2_MAX_LENGTH } from '@metaboost/helpers'; import { UserBio } from './UserBio.js'; import { UserCredentials } from './UserCredentials.js'; -import { UserTrustSettings } from './UserTrustSettings.js'; +import { UserMembership } from './UserMembership.js'; @Entity('user') export class User { @@ -36,6 +36,6 @@ export class User { @OneToOne(() => UserBio, (b) => b.user) bio!: UserBio; - @OneToOne(() => UserTrustSettings, (trustSettings) => trustSettings.user) - trustSettings!: UserTrustSettings; + @OneToOne(() => UserMembership, (membership) => membership.user) + membership!: UserMembership; } diff --git a/packages/orm/src/entities/UserTrustSettings.ts b/packages/orm/src/entities/UserMembership.ts similarity index 92% rename from packages/orm/src/entities/UserTrustSettings.ts rename to packages/orm/src/entities/UserMembership.ts index e80ceb90..e8e3d96d 100644 --- a/packages/orm/src/entities/UserTrustSettings.ts +++ b/packages/orm/src/entities/UserMembership.ts @@ -2,8 +2,8 @@ import type { User } from './User.js'; import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn } from 'typeorm'; -@Entity('user_trust_settings') -export class UserTrustSettings { +@Entity('user_membership') +export class UserMembership { @PrimaryColumn('uuid', { name: 'user_id' }) userId!: string; @@ -49,7 +49,7 @@ export class UserTrustSettings { @Column({ name: 'updated_at', type: 'timestamp' }) updatedAt!: Date; - @OneToOne('User', (u: User) => u.trustSettings, { onDelete: 'CASCADE' }) + @OneToOne('User', (u: User) => u.membership, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'user_id' }) user!: User; } diff --git a/packages/orm/src/index.ts b/packages/orm/src/index.ts index 29102bbe..1658228b 100644 --- a/packages/orm/src/index.ts +++ b/packages/orm/src/index.ts @@ -1,6 +1,6 @@ export { appDataSource, appDataSourceRead, appDataSourceReadWrite } from './data-source.js'; export { User } from './entities/User.js'; -export { UserTrustSettings } from './entities/UserTrustSettings.js'; +export { UserMembership } from './entities/UserMembership.js'; export { UserCredentials } from './entities/UserCredentials.js'; export { UserBio } from './entities/UserBio.js'; export { diff --git a/packages/orm/src/services/UserService.ts b/packages/orm/src/services/UserService.ts index 30097e33..6a530778 100644 --- a/packages/orm/src/services/UserService.ts +++ b/packages/orm/src/services/UserService.ts @@ -11,9 +11,9 @@ import { appDataSourceRead, appDataSourceReadWrite } from '../data-source.js'; import { User } from '../entities/User.js'; import { UserBio } from '../entities/UserBio.js'; import { UserCredentials } from '../entities/UserCredentials.js'; -import { UserTrustSettings } from '../entities/UserTrustSettings.js'; +import { UserMembership } from '../entities/UserMembership.js'; import { resolveDefaultMembershipExpiresAt } from '../lib/defaultMembershipExpiresAt.js'; -const USER_RELATIONS = ['credentials', 'bio', 'trustSettings'] as const; +const USER_RELATIONS = ['credentials', 'bio', 'membership'] as const; export class UserService { static async findById(id: string): Promise { @@ -89,7 +89,7 @@ export class UserService { const userRepo = qr.manager.getRepository(User); const credRepo = qr.manager.getRepository(UserCredentials); const bioRepo = qr.manager.getRepository(UserBio); - const trustSettingsRepo = qr.manager.getRepository(UserTrustSettings); + const membershipRepo = qr.manager.getRepository(UserMembership); const user = userRepo.create({ idText, @@ -117,14 +117,14 @@ export class UserService { premiumBillingCadence: data.premiumBillingCadence, }); const autoRenewVal = data.autoRenew ?? membershipTier === MembershipTier.Premium; - const trustSettings = trustSettingsRepo.create({ + const membership = membershipRepo.create({ userId: savedUser.id, membershipTier, membershipExpiresAt: data.membershipExpiresAt ?? defaultMembershipExpiresAt, autoRenew: autoRenewVal, autoRenewMode: autoRenewVal ? 'on' : 'off', }); - await trustSettingsRepo.save(trustSettings); + await membershipRepo.save(membership); await qr.commitTransaction(); const withRelations = await userRepo.findOne({ @@ -188,14 +188,14 @@ export class UserService { await repo.delete(userId); } - static async upsertTrustSettings(data: { + static async upsertMembership(data: { userId: string; membershipTier?: MembershipTier; membershipExpiresAt?: Date | null; autoRenew?: boolean; premiumBillingCadence?: PremiumBillingCadence; }): Promise { - const repo = appDataSourceReadWrite.getRepository(UserTrustSettings); + const repo = appDataSourceReadWrite.getRepository(UserMembership); const existing = await repo.findOne({ where: { userId: data.userId } }); const prevTier = diff --git a/packages/orm/src/services/billingRenewalOrchestrator.ts b/packages/orm/src/services/billingRenewalOrchestrator.ts index 1b36e67a..ba3c3a37 100644 --- a/packages/orm/src/services/billingRenewalOrchestrator.ts +++ b/packages/orm/src/services/billingRenewalOrchestrator.ts @@ -9,7 +9,7 @@ import { } from '@metaboost/helpers'; import { appDataSourceReadWrite } from '../data-source.js'; -import { UserTrustSettings } from '../entities/UserTrustSettings.js'; +import { UserMembership } from '../entities/UserMembership.js'; import { BillingDomainEventLogService } from './billingDomainEventLog.js'; import { MembershipPeriodExtensionService } from './membershipPeriodExtension.js'; @@ -62,7 +62,7 @@ export class BillingRenewalOrchestratorService { last_renewal_idempotency_key AS "lastRenewalIdempotencyKey", last_renewal_status AS "lastRenewalStatus", renewal_retry_count AS "renewalRetryCount" - FROM user_trust_settings + FROM user_membership WHERE membership_tier = $1 AND auto_renew_mode = $2 AND billing_cadence IS NOT NULL @@ -146,7 +146,7 @@ export class BillingRenewalOrchestratorService { now, manager, }); - await manager.getRepository(UserTrustSettings).update( + await manager.getRepository(UserMembership).update( { userId: row.userId }, { lastRenewalAttemptAt: now, @@ -200,7 +200,7 @@ export class BillingRenewalOrchestratorService { retryAfterSeconds: number; }): Promise { const nextAttempt = new Date(params.now.getTime() + params.retryAfterSeconds * 1000); - const repo = params.manager.getRepository(UserTrustSettings); + const repo = params.manager.getRepository(UserMembership); await repo.update( { userId: params.userId }, { diff --git a/packages/orm/src/services/membershipPeriodExtension.ts b/packages/orm/src/services/membershipPeriodExtension.ts index 69b9281c..3c713d01 100644 --- a/packages/orm/src/services/membershipPeriodExtension.ts +++ b/packages/orm/src/services/membershipPeriodExtension.ts @@ -9,7 +9,7 @@ import { } from '@metaboost/helpers'; import { appDataSourceReadWrite } from '../data-source.js'; -import { UserTrustSettings } from '../entities/UserTrustSettings.js'; +import { UserMembership } from '../entities/UserMembership.js'; import { BillingDomainEventLogService } from './billingDomainEventLog.js'; function eventTypeForExtensionReason( @@ -27,7 +27,7 @@ function eventTypeForExtensionReason( export class MembershipPeriodExtensionService { /** * Extends premium membership expiry using {@link extendMembershipPeriodByCadence} and records - * idempotency on trust settings. Delegates all calendar math to helpers. + * idempotency on membership. Delegates all calendar math to helpers. */ static async extendPremiumByCadence(params: { userId: string; @@ -44,30 +44,30 @@ export class MembershipPeriodExtensionService { applied: boolean; membershipExpiresAt: Date | null; }> => { - const repo = manager.getRepository(UserTrustSettings); - const trust = await repo.findOne({ where: { userId: params.userId } }); - if (trust === null) { + const repo = manager.getRepository(UserMembership); + const membership = await repo.findOne({ where: { userId: params.userId } }); + if (membership === null) { throw new Error( - 'MembershipPeriodExtensionService.extendPremiumByCadence: trust settings missing' + 'MembershipPeriodExtensionService.extendPremiumByCadence: membership missing' ); } - if (trust.membershipTier !== MembershipTier.Premium) { + if (membership.membershipTier !== MembershipTier.Premium) { throw new Error( 'MembershipPeriodExtensionService.extendPremiumByCadence: user is not premium' ); } - if (trust.lastExtensionIdempotencyKey === params.idempotencyKey) { - return { applied: false, membershipExpiresAt: trust.membershipExpiresAt }; + if (membership.lastExtensionIdempotencyKey === params.idempotencyKey) { + return { applied: false, membershipExpiresAt: membership.membershipExpiresAt }; } const newExpiry = extendMembershipPeriodByCadence({ - membershipExpiresAt: trust.membershipExpiresAt, + membershipExpiresAt: membership.membershipExpiresAt, cadence: params.cadence, now, }); - trust.membershipExpiresAt = newExpiry; - trust.billingCadence = params.cadence; - trust.lastExtensionIdempotencyKey = params.idempotencyKey; - await repo.save(trust); + membership.membershipExpiresAt = newExpiry; + membership.billingCadence = params.cadence; + membership.lastExtensionIdempotencyKey = params.idempotencyKey; + await repo.save(membership); const eventType = eventTypeForExtensionReason(params.reason); if (eventType !== null) { diff --git a/packages/orm/src/types/UserWithRelations.ts b/packages/orm/src/types/UserWithRelations.ts index 72a03f73..5062ae06 100644 --- a/packages/orm/src/types/UserWithRelations.ts +++ b/packages/orm/src/types/UserWithRelations.ts @@ -1,7 +1,7 @@ import type { User } from '../entities/User.js'; import type { UserBio } from '../entities/UserBio.js'; import type { UserCredentials } from '../entities/UserCredentials.js'; -import type { UserTrustSettings } from '../entities/UserTrustSettings.js'; +import type { UserMembership } from '../entities/UserMembership.js'; /** * User with credentials and bio relations loaded (e.g. from UserService.findById/findByEmail/create). @@ -9,5 +9,5 @@ import type { UserTrustSettings } from '../entities/UserTrustSettings.js'; export type UserWithRelations = User & { credentials: UserCredentials; bio: UserBio | null; - trustSettings: UserTrustSettings | null; + membership: UserMembership | null; }; diff --git a/tools/web/seed-e2e.mjs b/tools/web/seed-e2e.mjs index 34ac2138..cee105b3 100644 --- a/tools/web/seed-e2e.mjs +++ b/tools/web/seed-e2e.mjs @@ -211,7 +211,7 @@ async function main() { E2E_DISPLAY_NAME9, ]); await client.query( - `INSERT INTO user_trust_settings ( + `INSERT INTO user_membership ( user_id, membership_tier, membership_expires_at,