Skip to content

Commit a6eb897

Browse files
authored
Merge branch 'main' into datasource
2 parents 308d443 + 8496ef9 commit a6eb897

49 files changed

Lines changed: 1743 additions & 65 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,55 @@ jobs:
6565
run: npm run build
6666
working-directory: backend
6767

68+
backend-e2e:
69+
name: Backend E2E
70+
runs-on: ubuntu-latest
71+
timeout-minutes: 20
72+
73+
services:
74+
postgres:
75+
image: postgres:16-alpine
76+
env:
77+
POSTGRES_USER: myfans_ci
78+
POSTGRES_PASSWORD: myfans_ci
79+
POSTGRES_DB: myfans_test
80+
ports:
81+
- 5432:5432
82+
options: >
83+
--health-cmd pg_isready
84+
--health-interval 5s
85+
--health-timeout 5s
86+
--health-retries 10
87+
88+
env:
89+
DB_HOST: localhost
90+
DB_PORT: 5432
91+
DB_USER: myfans_ci
92+
DB_PASSWORD: myfans_ci
93+
DB_NAME: myfans_test
94+
JWT_SECRET: ci-test-secret-not-for-production
95+
WEBHOOK_SECRET: ci-webhook-secret-not-for-production
96+
NODE_ENV: test
97+
STELLAR_NETWORK: testnet
98+
SOROBAN_RPC_URL: https://soroban-testnet.stellar.org
99+
100+
steps:
101+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
102+
103+
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
104+
with:
105+
node-version: '20'
106+
cache: npm
107+
cache-dependency-path: backend/package-lock.json
108+
109+
- name: Install dependencies
110+
run: npm ci
111+
working-directory: backend
112+
113+
- name: Run e2e suite
114+
run: npm run test:e2e
115+
working-directory: backend
116+
68117
frontend:
69118
name: Frontend
70119
runs-on: ubuntu-latest

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# 0.1.0 (2026-07-25)
1+
# 0.1.0 (2026-07-26)
22

33

44
### Bug Fixes
@@ -216,6 +216,7 @@
216216
* **backend:** add Soroban RPC retry/backoff utility with circuit breaker ([#343](https://github.com/MyFanss/MyFans/issues/343)) ([24dbfe7](https://github.com/MyFanss/MyFans/commit/24dbfe7e431ba12e98b26228c1f64556b5e0c9dc))
217217
* **backend:** auto-load contract IDs from deploy artifacts ([dc50afe](https://github.com/MyFanss/MyFans/commit/dc50afe5e2affd56fc3f7324707bd87119d4514b))
218218
* **backend:** creator dashboard endpoint for revenue and subscriber metrics ([d1a39ea](https://github.com/MyFanss/MyFans/commit/d1a39eab6d25c3d77ae84a590a73545515d2e1ed))
219+
* **backend:** e2e CI job, readiness probe, earnings module, security tracker ([c597a6c](https://github.com/MyFanss/MyFans/commit/c597a6c25c098a2248dcdab551f453e38d8990e7)), closes [#1444](https://github.com/MyFanss/MyFans/issues/1444) [#1443](https://github.com/MyFanss/MyFans/issues/1443) [#1445](https://github.com/MyFanss/MyFans/issues/1445)
219220
* **backend:** harden CreatorsService with Logger and resilient edges ([d4418ed](https://github.com/MyFanss/MyFans/commit/d4418edfe825c4d03bf7cf0363ab8a2edcdcae9c))
220221
* **backend:** IPFS metadata upload flow ([c46de68](https://github.com/MyFanss/MyFans/commit/c46de682411b3f69f24ccd4e450ae743c4d41629))
221222
* **backend:** social-links service tests, DTO validation, e2e coverage, pagination ([2f1db47](https://github.com/MyFanss/MyFans/commit/2f1db47e7ae4491bf0cae7789cd1fe7e661b5e08))

DEVELOPMENT.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,31 @@ Or open `http://localhost:3001/v1/health` in your browser.
5656

5757
---
5858

59+
## Frontend API base URL (`NEXT_PUBLIC_API_URL`)
60+
61+
All frontend API clients resolve the backend origin through the shared
62+
`getApiBaseUrl()` helper in `frontend/src/lib/api/base-url.ts`, instead of
63+
each module hardcoding its own `localhost` host/port fallback.
64+
65+
- Set `NEXT_PUBLIC_API_URL` to override the backend origin, e.g. in
66+
`frontend/.env.local`:
67+
```bash
68+
NEXT_PUBLIC_API_URL=http://localhost:3001
69+
```
70+
- When unset, it defaults to `http://localhost:3001` (matching the backend's
71+
`docker-compose.yml`/`docker-compose.dev.yml` port), so local dev works out
72+
of the box without any frontend env file.
73+
- `NEXT_PUBLIC_API_URL` should be the bare origin (protocol + host + optional
74+
port) — individual clients append their own resource paths (e.g. `/v1/...`,
75+
`/api/v1/...`, `/favorites`) on top of it. Do not include a trailing slash.
76+
- If you add a new frontend module that calls the backend, import
77+
`getApiBaseUrl()` (or `getConfiguredApiBaseUrl()` if you need a different
78+
fallback than the shared absolute default, e.g. a same-origin relative URL)
79+
from `@/lib/api/base-url` rather than reading
80+
`process.env.NEXT_PUBLIC_API_URL` directly.
81+
82+
---
83+
5984
## Contract Development
6085

6186
Contracts are in the `contract/` directory and use Soroban SDK.

SECURITY.md

Lines changed: 108 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ If you discover a security vulnerability in MyFans, please report it responsibly
2323
| Component | Last Tested | Status | Critical Issues | High Issues | Medium Issues |
2424
|-----------|-------------|--------|-----------------|-------------|---------------|
2525
| Frontend | - | Pending | 0 | 0 | 0 |
26-
| Backend | - | Pending | 0 | 0 | 0 |
26+
| Backend | 2026-07-25 | Reviewed | 0 | 0 | 3 |
2727
| Contracts | - | Pending | 0 | 0 | 0 |
2828

2929
### Findings Log
@@ -45,13 +45,117 @@ If you discover a security vulnerability in MyFans, please report it responsibly
4545

4646
### Active Findings
4747

48-
*No active findings at this time*
48+
```
49+
### Finding #6 - Low - 2026-07-25
50+
**Component**: Backend
51+
**Category**: Dead Code / Attack Surface
52+
**Description**: Deprecated duplicate auth stacks (`src/auth`, `src/refresh-module`,
53+
`src/users-module`) remain in the tree alongside the canonical `src/auth-module` +
54+
`src/users` stack, each with their own JWT signing/expiry configuration
55+
(`backend/src/auth-module/auth.module.ts` vs `backend/src/users/users.module.ts`).
56+
They are not wired into `AppModule` (see the comment at the top of
57+
`backend/src/app.module.ts`), so they are not reachable at runtime today.
58+
**Impact**: Divergent, largely untested duplicate auth code increases the chance
59+
that a future refactor or module-wiring change accidentally reintroduces one of
60+
these stacks (or a bypass) into the request path.
61+
**Status**: Open
62+
**Assigned To**: Backend team
63+
**Resolution**: Pending — delete the deprecated modules once call sites are confirmed
64+
fully migrated to `auth-module`.
65+
**Resolved Date**: -
66+
```
4967

5068
---
5169

5270
### Resolved Findings
5371

54-
*No resolved findings yet*
72+
```
73+
### Finding #1 - Medium - 2026-07-25
74+
**Component**: Backend
75+
**Category**: Access Control / Observability (shallow liveness)
76+
**Description**: `HealthService.getHealth()` (`backend/src/health/health.service.ts`)
77+
always returned a static `up` without probing the database or any other
78+
subsystem, and it was the only liveness/readiness signal exposed.
79+
**Impact**: An orchestrator (k8s liveness/readiness probe, load balancer health
80+
check) polling `GET /health` would see a healthy instance even when its database
81+
connection was completely down, keeping traffic routed to a non-functional pod
82+
instead of failing over.
83+
**Status**: Resolved
84+
**Assigned To**: Backend team
85+
**Resolution**: Added `GET /v1/health/ready`, which probes the database
86+
(mandatory — 503 on failure) and Soroban RPC (optional, reported only).
87+
`GET /v1/health` remains a pure liveness check by design (see issue #1443).
88+
**Resolved Date**: 2026-07-25
89+
90+
### Finding #2 - Medium - 2026-07-25
91+
**Component**: Backend
92+
**Category**: CI/CD Gap
93+
**Description**: CI (`.github/workflows/ci.yml`) ran only the unit test suite
94+
(`npm test`) on PRs. The e2e suite — which includes the access-control and
95+
transport-security regression tests in `backend/test/rbac.e2e-spec.ts`,
96+
`cors-security.e2e-spec.ts`, and `security-hardening.e2e-spec.ts` — was never
97+
executed automatically.
98+
**Impact**: A regression in RBAC enforcement, CORS policy, or other
99+
security-hardening behavior covered only by e2e tests could be merged to `main`
100+
without CI catching it.
101+
**Status**: Resolved
102+
**Assigned To**: Backend team
103+
**Resolution**: Added a `Backend E2E` job with a Postgres service to
104+
`.github/workflows/ci.yml`, running `npm run test:e2e` on every PR; local run
105+
steps documented in `DEVELOPMENT.md` (see issue #1444).
106+
**Resolved Date**: 2026-07-25
107+
108+
### Finding #3 - Low - 2026-07-25
109+
**Component**: Backend
110+
**Category**: Process
111+
**Description**: This `SECURITY.md` findings tracker existed only as an empty
112+
template despite known, addressable exposures already present in the backend.
113+
**Impact**: Prior and ongoing security work was not discoverable from the
114+
document meant to track it, undermining the audit trail for reviewers.
115+
**Status**: Resolved
116+
**Assigned To**: Backend team
117+
**Resolution**: Populated with the findings in this section (see issue #1445).
118+
**Resolved Date**: 2026-07-25
119+
120+
### Finding #4 - Medium - 2026-07-25
121+
**Component**: Backend
122+
**Category**: Access Control (missing authorization boundary)
123+
**Description**: The frontend expects creator-scoped `/earnings/*` endpoints,
124+
but the only server-side aggregation was `AnalyticsController`
125+
(`GET /v1/analytics/*`), which is shared between admins and creators, gated
126+
by a manual `scopeToOwner` check, and was not even registered in `AppModule`.
127+
**Impact**: Without a dedicated, strictly-scoped earnings surface, there was
128+
pressure to either bypass the backend for financial data or hand-roll a new
129+
endpoint without the existing admin/creator scoping discipline.
130+
**Status**: Resolved
131+
**Assigned To**: Backend team
132+
**Resolution**: Added `EarningsModule` (`backend/src/earnings/`), gated by
133+
`@Roles(UserRole.CREATOR)` with every query always scoped to
134+
`req.user.userId` — no cross-creator or admin override path exists on this
135+
controller (see issue #1438).
136+
**Resolved Date**: 2026-07-25
137+
```
138+
139+
---
140+
141+
### Accepted Risks
142+
143+
```
144+
### Finding #5 - Low - 2026-07-25
145+
**Component**: Backend
146+
**Category**: Data Exposure (credentials in transit)
147+
**Description**: The Redis health probe (`pingRedis` in
148+
`backend/src/health/health.service.ts`) sends `AUTH <password>` in the clear
149+
when `REDIS_URL` uses the non-TLS `redis://` scheme instead of `rediss://`.
150+
**Impact**: On a network segment an attacker can observe, the Redis credential
151+
used for the health check could be captured.
152+
**Status**: Accepted Risk
153+
**Assigned To**: Backend team
154+
**Resolution**: Accepted — the probe only runs over the internal
155+
Docker/VPC network in current deployments. Recommend switching to `rediss://`
156+
in any environment where that network boundary is not trusted.
157+
**Resolved Date**: -
158+
```
55159

56160
---
57161

@@ -143,4 +247,4 @@ MyFans adheres to:
143247

144248
This document is reviewed and updated quarterly or after significant security events.
145249

146-
**Last Updated**: 2026-04-22
250+
**Last Updated**: 2026-07-25
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Creator Registry Sync (#1454)
2+
3+
Keeps the off-chain `CreatorProfile` (`creators` table) in sync with the
4+
numeric `creator_id` registered on-chain in the
5+
[`creator-registry`](../../contract/docs/interfaces/creator-registry.md)
6+
Soroban contract, via a new `creator_onchain_mappings` table.
7+
8+
## Why
9+
10+
The on-chain registry (`register_creator(caller, creator_address, creator_id)`)
11+
and the backend's `CreatorProfile` are independent sources of truth keyed
12+
differently (Stellar address vs. internal UUID). Without an explicit mapping
13+
+ drift check, the two can silently diverge (e.g. a creator re-registers with
14+
a new `creator_id`, or a registration transaction fails after the backend
15+
already recorded it as successful).
16+
17+
## Components
18+
19+
- **Entity**: `backend/src/creators/entities/creator-onchain-mapping.entity.ts`
20+
— one row per creator: `creator_id` (FK → `creators.id`), `stellar_address`,
21+
`onchain_creator_id`, `last_synced_at`, `drift_detected_at`.
22+
- **Migration**: `backend/src/creators/1749000000000-CreateCreatorOnchainMappings.ts`.
23+
- **Service**: `backend/src/creators/creator-registry-sync.service.ts`
24+
(`CreatorRegistrySyncService`):
25+
- `syncOnOnboard(creatorId, stellarAddress, onchainCreatorId)` — upserts the
26+
mapping. Call this right after `creator-registry.register_creator`
27+
succeeds during onboarding.
28+
- `reconcile(dryRun?)` — re-checks every mapped creator's on-chain
29+
`creator_id` and flags rows where it disagrees with what's stored
30+
(`drift_detected_at`). Runs hourly via `@Cron` (see
31+
`CREATOR_REGISTRY_RECONCILER_DRY_RUN` env var to run without persisting),
32+
mirroring `SubscriptionReconcilerService`.
33+
- **Endpoint**: `POST /v1/creators/:creatorId/onchain-sync` — thin wrapper
34+
around `syncOnOnboard` for the onboarding flow.
35+
36+
## Current limitation
37+
38+
`CreatorRegistrySyncService.queryOnchainCreatorId()` is currently a stub
39+
(always returns `null`), matching the same convention as
40+
`SubscriptionReconcilerService.queryChainExpiry()`. Wiring it up to a real
41+
Soroban contract read (via `SorobanRpcService`, following the pattern in
42+
`SubscriptionChainReaderService`) against the deployed `creator-registry`
43+
contract's `get_creator_id` is tracked as follow-up work — until then,
44+
`reconcile()` will flag every mapped creator as drifted, so treat its output
45+
as informational rather than actionable in production.

backend/src/analytics/analytics.module.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,6 @@ import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
77
imports: [SubscriptionsModule],
88
controllers: [AnalyticsController],
99
providers: [AnalyticsService],
10+
exports: [AnalyticsService],
1011
})
1112
export class AnalyticsModule {}

backend/src/app.module.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,8 @@ import { FeatureFlagsModule } from './feature-flags/feature-flags.module';
2727
import { ReferralModule } from './referral/referral.module';
2828
import { CsrfModule } from './csrf/csrf.module';
2929
import { SocialLinksModule } from './social-link/social-links.module';
30-
import { FeedModule } from './feed/feed.module';
31-
import { FavoritesModule } from './favorites/favorites.module';
32-
import { NetworkConfigModule } from './config/network-config.module';
30+
import { AnalyticsModule } from './analytics/analytics.module';
31+
import { EarningsModule } from './earnings/earnings.module';
3332
import { CsrfMiddleware } from './common/middleware/csrf.middleware';
3433
import { CorrelationExceptionFilter } from './common/filters/correlation-exception.filter';
3534
import { RequestContextService } from './common/services/request-context.service';
@@ -67,9 +66,8 @@ const IDEMPOTENCY_ROUTES = [
6766
ReferralModule,
6867
CsrfModule,
6968
SocialLinksModule,
70-
FeedModule,
71-
FavoritesModule,
72-
NetworkConfigModule,
69+
AnalyticsModule,
70+
EarningsModule,
7371
],
7472
controllers: [AppController, OpenAPIController],
7573
providers: [
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { MigrationInterface, QueryRunner } from 'typeorm';
2+
3+
/**
4+
* Creates `creator_onchain_mappings`, tracking the creator-registry
5+
* contract's `creator_id` (u64) against the off-chain `creators` row that
6+
* registered it (#1454).
7+
*
8+
* Safe to run against both fresh and existing databases — every statement is
9+
* conditional (`IF NOT EXISTS`) so a database that already picked up the
10+
* table via `synchronize` is left untouched.
11+
*/
12+
export class CreateCreatorOnchainMappings1749000000000 implements MigrationInterface {
13+
name = 'CreateCreatorOnchainMappings1749000000000';
14+
15+
public async up(queryRunner: QueryRunner): Promise<void> {
16+
await queryRunner.query(`
17+
CREATE TABLE IF NOT EXISTS "creator_onchain_mappings" (
18+
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
19+
"creator_id" uuid NOT NULL,
20+
"stellar_address" varchar(56) NOT NULL,
21+
"onchain_creator_id" varchar NOT NULL,
22+
"last_synced_at" TIMESTAMPTZ NOT NULL,
23+
"drift_detected_at" TIMESTAMPTZ,
24+
"created_at" TIMESTAMPTZ NOT NULL DEFAULT now(),
25+
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT now(),
26+
CONSTRAINT "PK_creator_onchain_mappings" PRIMARY KEY ("id"),
27+
CONSTRAINT "UQ_creator_onchain_mappings_creator_id" UNIQUE ("creator_id"),
28+
CONSTRAINT "FK_creator_onchain_mappings_creator_id" FOREIGN KEY ("creator_id")
29+
REFERENCES "creators" ("id") ON DELETE CASCADE
30+
);
31+
`);
32+
33+
await queryRunner.query(`
34+
CREATE INDEX IF NOT EXISTS "IDX_creator_onchain_mappings_onchain_creator_id"
35+
ON "creator_onchain_mappings" ("onchain_creator_id");
36+
`);
37+
}
38+
39+
public async down(queryRunner: QueryRunner): Promise<void> {
40+
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_creator_onchain_mappings_onchain_creator_id";`);
41+
await queryRunner.query(`DROP TABLE IF EXISTS "creator_onchain_mappings";`);
42+
}
43+
}

0 commit comments

Comments
 (0)