Every security mechanism in Sharetopus, organized by the attack it stops, the code that implements the defense, and the known gaps.
- Defense Layers
- Threat Model
- Identity Flow
- Rate Limiting
- Idempotency
- SSRF Guard
- Storage Quota Enforcement
- TikTok HMAC Media Proxy
- Webhook Verification
- Append-Only Audit
- Data Protection
- XSS Prevention
- Known Gaps
- Compliance Posture
- Source Files Referenced
Every inbound request passes through these layers in order. A failure at any layer rejects the request immediately.
flowchart LR
A["Network\n(TLS, DNS)"] --> B["Rate Limit\n(Upstash sliding window)"]
B --> C["Auth\n(API key or Clerk JWT)"]
C --> D["Entitlement\n(plan tier + monthly quota)"]
D --> E["Handler\n(tool logic, SSRF guard,\nquota check)"]
E --> F["Audit\n(append-only log,\narg redaction)"]
style A fill:#e8e8e8,stroke:#666
style B fill:#ffd6d6,stroke:#c44
style C fill:#fff3cd,stroke:#a80
style D fill:#d4edda,stroke:#2a5
style E fill:#cce5ff,stroke:#36a
style F fill:#e2d5f1,stroke:#74b
MCP route-level rate limiting (1000/60s per IP, a flood guard) runs before auth and answers 429, never 401. All other rate limits run after auth, scoped to the authenticated principal, including the MCP tool-call budget (100/60s per principal).
| # | Attack | Defense | Implementation |
|---|---|---|---|
| 1 | Duplicate posts from MCP retry | idempotency_key + DB UNIQUE constraint |
schedulePost.ts, postNow.ts, bulkPostNow.ts, bulkSchedule.ts |
| 2 | SSRF via attach_media_from_url (e.g. cloud metadata at 169.254.169.254) |
safeUserFetch with 14 IP ranges blocked |
safeUserFetch.ts |
| 3 | Oversized file with fake Content-Length | Stream-based byte counter (Content-Length never trusted) | safeUserFetch.ts |
| 4 | Storage quota bypass | enforceStorageQuota via get_user_storage_bytes RPC |
enforceStorageQuota.ts |
| 5 | attach_media_from_url flood |
10/60s rate limit + monthly cap per tier | attachMediaFromUrl.ts |
| 6 | Cross-user storage access | Path startsWith(principalId/) check |
/api/media |
| 7 | TikTok media URL forgery | HMAC-SHA256 + 30-min expiry | buildProxiedTikTokMediaUrl.ts |
| 8 | Media proxy path traversal | Block .., //, leading / |
/api/media/route.ts |
| 9 | Audit log tampering | Append-only table (reject_mutation DB trigger refuses UPDATE and DELETE, except retention deletes and ON DELETE SET NULL detaching a deleted principal) |
mcp_audit_log table |
| 10 | Concurrent quota race condition | atomic_increment_quota Postgres function |
entitlement.ts |
| 11 | Monthly cap exhaustion | Per-tier quotas enforced atomically | entitlement.ts |
| 12 | IP tracking privacy leak | SHA-256 hash with configurable salt | ipHash.ts |
| 13 | Sensitive args in audit log | Regex redaction of 12 key patterns + JWT detection | audit.ts |
| 14 | Unauthorized MCP access flood | Route-level rate limit: 1000/60s per IP (before auth) | MCP route handler |
| 15 | XSS via MCP clientInfo |
sanitizeClientField strips control chars + HTML injection chars |
MCP server init |
| 16 | Stripe webhook replay | Signature verification + stripe_webhook_events idempotency table |
Stripe webhook handler |
| 17 | TikTok webhook replay | HMAC-SHA256 + 300s tolerance + tiktok_webhook_events idempotency table |
TikTok webhook handler |
| 18 | REST API unauthorized access | Bearer token (stp_rest_*) + SHA-256 hash lookup + revocation check |
withRestEndpoint.ts |
| 19 | REST API request forgery | Per-principal rate limiting + append-only rest_audit_log |
withRestEndpoint.ts, writeRestAuditLog.ts |
| 20 | Outbound webhook forgery | HMAC-SHA256 per-subscription signing with X-Sharetopus-Signature header |
signWebhookPayload.ts, deliverWebhook.ts |
| 21 | Webhook endpoint abuse | Auto-disable after 10 consecutive delivery failures | deliverWebhook.ts (AUTO_DISABLE_THRESHOLD = 10) |
Three auth surfaces share the same identity model. MCP and REST API both resolve to a principal with subscription tier. Free and Starter tiers are blocked at the MCP subscription gate (Creator+ required for MCP). REST API auth is handled by withRestEndpoint in src/lib/api/rest/middleware/withRestEndpoint.ts, which resolves stp_rest_* Bearer tokens via SHA-256 hash lookup in api_keys (where kind = 'rest'). Every REST request is logged to the append-only rest_audit_log table.
sequenceDiagram
participant C as Client (browser / MCP agent)
participant R as Route handler
participant A as resolveMcpPrincipal
participant Clerk as Clerk SDK
participant DB as Supabase
C->>R: Request with Authorization: Bearer <token>
R->>A: Extract bearer token
alt Token starts with stp_mcp_
A->>A: SHA-256 hash the token
A->>DB: SELECT from api_keys WHERE token_hash = hash
A->>A: Verify: not revoked, not expired
A->>DB: UPDATE api_keys SET last_used_at = now()
else Clerk OAuth token (JWT)
A->>Clerk: Verify JWT (signature, expiry, audience)
Clerk-->>A: userId
end
A->>DB: checkActiveSubscription (stripe_subscriptions)
DB-->>A: { isActive, plan, priceId }
alt a database read failed
A-->>R: unavailable (503 with Retry-After, token not dropped)
else not active OR plan below Creator
A-->>R: rejected (401 invalid_token, fail-closed)
else active with Creator+ plan
A->>A: priceIdToTier(priceId)
A-->>R: McpPrincipal { principalId, kind, plan, priceId, scopes }
R->>R: Inject principal into tool context
end
Fail-closed behavior. If checkActiveSubscription returns isActive: false (no subscription, or a read that failed) or the plan is below Creator, the request is blocked. No principal is returned. A failed read (status unavailable), like any failed database read while resolving the token, answers 503 instead of 401, so a client keeps its valid token and retries. It is not cached, so the next request reads again instead of staying locked out for the cache's 60 seconds.
checkActiveSubscription is server-only: it trusts the user id it is given, so it is never exposed as a server action. Browser code asks about the current user's subscription through createCustomerPortal, which reads the id from the Clerk session.
McpPrincipal type (discriminated union):
kind: "apikey"carriesapiKeyId,scopeskind: "oauth"carriesoauthClientId,scopes- Both carry
principalId,plan(PlanTier),priceId
The plan tier is resolved once during auth and cached on the principal. Tools and entitlement checks read principal.plan without querying stripe_subscriptions again.
All per-request rate limits use Upstash Redis sliding window (@upstash/ratelimit).
| Path | Scope | Limit | Window | Storage |
|---|---|---|---|---|
| MCP route (pre-auth) | per IP | 1000 | 60s | Upstash |
| MCP tool calls (all tools) | per principal | 100 | 60s | Upstash |
attach_media_from_url |
per principal | 10 | 60s | Upstash |
request_upload_url |
per principal | 20 | 60s | Upstash |
handleSocialMediaPost |
per user | 30 | 60s | Upstash |
checkOutSession |
per user | 15 | 60s | Upstash |
createCustomerPortal |
per user | 20 | 60s | Upstash |
directPostBatch |
per source | 20 | 60s | Upstash |
schedulePostBatch |
per source | 10 | 60s | Upstash |
| REST API endpoints | per principal | varies per endpoint | varies | Upstash |
| New OAuth client (first sight) | per user | 3/min, 10/day | Upstash, hits logged to rate_limit_events |
|
| Pinterest board listing | per account | 15 | 60s | Upstash |
Enforced by entitlementFor in src/lib/mcp/entitlement.ts via atomic_increment_quota Postgres RPC. The increment is atomic at the database level, preventing race conditions between concurrent requests.
| Action | Creator | Pro |
|---|---|---|
publish_posts |
500 | unlimited |
request_upload_url |
500 | unlimited |
attach_media_from_url |
500 | unlimited |
Starter tier: 0 for all actions (completely blocked from MCP at the auth gate).
Period key format: YYYY-MM-01 (first of month, UTC), generated by currentQuotaPeriod().
Every path that creates posts supports Stripe-style idempotent retries. An agent that retries after a network error with the same key gets back the original result instead of creating a duplicate. In MCP the key comes from publish_posts' batch_id (post N gets ${batch_id}:${N}); REST and x402 take an idempotency_key per post.
sequenceDiagram
participant A as Agent
participant T as Tool handler
participant DB as Supabase
A->>T: publish_posts {batch_id: "abc", posts: [one post]}
T->>DB: SELECT pending_direct_posts WHERE principal_id = ? AND idempotency_key = "abc:0"
DB-->>T: no row
T->>DB: INSERT pending_direct_posts (lock row)
T->>T: Dispatch Inngest post.now event
T->>A: { event_id: "evt_1", success: true }
Note over A: Network blip. Agent retries.
A->>T: publish_posts {batch_id: "abc", posts: [one post]}
T->>DB: SELECT pending_direct_posts WHERE principal_id = ? AND idempotency_key = "abc:0"
DB-->>T: existing event_id
T->>A: { event_id: "evt_1" } (not dispatched again)
| Caller | Key source | Target table |
|---|---|---|
MCP publish_posts, post with scheduled_at |
Derived: ${batch_id}:${index} from the batch_id param |
scheduled_posts |
MCP publish_posts, post without scheduled_at |
Derived: ${batch_id}:${index} from the batch_id param |
pending_direct_posts |
REST POST /v1/posts, x402 posting |
idempotency_key param (optional, 1-200 chars) |
the table of the path taken |
REST POST /v1/posts/bulk |
idempotency_key per post, else ${batch_id}:${index} |
scheduled_posts |
DB enforcement: a unique constraint on (principal_id, idempotency_key) on both tables (scheduled_posts_principal_idem_uq, pending_direct_posts_principal_idem_uq); rows without a key never collide, since NULLs are distinct. schedulePostBatch inserts with ON CONFLICT (principal_id, idempotency_key) DO NOTHING. directPostBatch looks up existing keys before dispatching and treats a unique violation (23505) on the lock insert as already dispatched.
The key is optional. Omitting it (no batch_id in MCP) means no deduplication: every call creates new rows. Agents should always supply one when retries are possible.
The scheduled-posts-tick cron uses eventId = ${postId}:${scheduledAt} with a 24-hour dedup window in Inngest, preventing duplicate dispatch if the cron fires twice for the same batch.
safeUserFetch protects attach_media_from_url from Server-Side Request Forgery. An agent could submit a URL pointing at internal infrastructure (cloud metadata endpoints, private services). The guard blocks this at multiple layers.
flowchart TD
A[args.url from agent] --> B{URL parses?}
B -- no --> X1[reject: invalid_url]
B -- yes --> C{Scheme http or https?}
C -- no --> X2[reject: blocked_scheme]
C -- yes --> D{Hostname is literal IP?}
D -- yes --> E[Validate IP directly]
D -- no --> F["DNS lookup (all: true, verbatim: false)"]
F --> G{Every resolved IP safe?}
E --> G
G -- no --> X3[reject: blocked_ip]
G -- yes --> H["fetch(url, redirect: 'manual')"]
H --> I{3xx response?}
I -- yes --> X4[reject: redirect_not_allowed]
I -- no --> J{Content-type in allowlist?}
J -- no --> X5[reject: content_type_not_allowed]
J -- yes --> K[Stream body with byte counter]
K --> L{Bytes exceed maxBytes?}
L -- yes --> X6[abort: too_large]
L -- no --> M[Return Buffer + metadata]
style X1 fill:#fdd,stroke:#c44
style X2 fill:#fdd,stroke:#c44
style X3 fill:#fdd,stroke:#c44
style X4 fill:#fdd,stroke:#c44
style X5 fill:#fdd,stroke:#c44
style X6 fill:#fdd,stroke:#c44
style M fill:#dfd,stroke:#2a5
14 ranges covering IPv4 and IPv6. Unrecognized IP formats fail closed (treated as blocked).
IPv4:
| CIDR | Description |
|---|---|
0.0.0.0/8 |
Unspecified |
10.0.0.0/8 |
Private (RFC 1918) |
100.64.0.0/10 |
CGNAT (RFC 6598) |
127.0.0.0/8 |
Loopback |
169.254.0.0/16 |
Link-local |
172.16.0.0/12 |
Private (RFC 1918) |
192.168.0.0/16 |
Private (RFC 1918) |
224.0.0.0/4 |
Multicast |
240.0.0.0/4 |
Reserved + broadcast |
IPv6:
| CIDR | Description |
|---|---|
::1/128 |
Loopback |
::/128 |
Unspecified |
fe80::/10 |
Link-local |
fc00::/7 |
Unique Local Address (ULA) |
::ffff:0:0/96 |
IPv4-mapped (embedded IPv4 re-checked against IPv4 ranges) |
- Scheme validation. Only
http:andhttps:are allowed.file:,ftp:,data:,gopher:are rejected. - Redirect blocking.
fetchis called withredirect: "manual". Any 3xx response is rejected. This prevents TOCTOU attacks where DNS resolves to a safe IP but the redirect target is internal. - Content-type validation. Response content-type is checked against an allowlist (prefix match + exact match). Strips parameters before comparison.
- Stream-based byte counter. Body is read chunk-by-chunk via
response.body.getReader(). A runningbyteCountis compared againstmaxByteson each chunk. If exceeded, the fetch is aborted. The Content-Length header is never trusted. - Timeouts. Connect timeout (5s) and total timeout (30s) via AbortController.
- User-Agent. Requests identify as
Sharetopus-MCP/1.0.
Three upload paths all converge on a single enforcement function.
flowchart LR
subgraph "Upload paths"
A1["Web /api/storage\n/generate-upload-url"]
A2["MCP\nrequest_upload_url"]
A3["MCP\nattach_media_from_url"]
end
subgraph "Quota check"
E["enforceStorageQuota()"]
F["get_user_storage_bytes\nPostgres RPC"]
E --> F
end
subgraph "Storage"
S[("scheduled-videos\nbucket")]
end
A1 --> E
A2 --> E
A3 --> SSRF["safeUserFetch\n(SSRF guard)"] --> SizeCap["Per-type\nsize cap"] --> E
E -->|"projected <= cap"| Mint["Mint signed URL\nor upload"]
E -->|"projected > cap"| Reject["Return quota_exceeded"]
Mint --> S
style Reject fill:#fdd,stroke:#c44
style Mint fill:#dfd,stroke:#2a5
enforceStorageQuota is the single enforcement point for all three upload paths:
- Calls
get_user_storage_bytesPostgres RPC with_bucket = MEDIA_BUCKET(src/lib/storage/mediaBucket.ts) and_prefix = "{principalId}/". - The RPC reads
storage.objectsdirectly (no pagination, no estimation). - Computes
projected = currentBytes + additionalBytes. - Compares against
STORAGE_LIMITS[priceId](falls back to 5 GB default). - Returns allow or deny with current/cap in the error message.
| Plan | Storage cap |
|---|---|
| Starter | 5 GB |
| Creator | 15 GB |
| Pro | 45 GB |
| Default (unknown priceId) | 5 GB |
Per-file size caps (all plans): image 8 MB, video 250 MB.
TikTok's pull model requires a publicly accessible URL for media. Sharetopus uses an HMAC-signed proxy (/api/media) to serve files without exposing storage credentials.
sequenceDiagram
participant Worker as Inngest worker
participant Builder as buildProxiedTikTokMediaUrl
participant TikTok as TikTok API
participant Proxy as /api/media
participant SB as Supabase Storage
Worker->>Builder: buildProxiedTikTokMediaUrl(principalId, mediaPath)
Builder->>Builder: HMAC payload: principalId + ":" + mediaPath + ":" + expiresAt
Builder->>Builder: Sign with MEDIA_PROXY_HMAC_SECRET (SHA-256)
Builder-->>Worker: Signed URL with ?file=&user=&expires=&sig=
Worker->>TikTok: POST /v2/post/publish/video/init/ (video_url = signed URL)
TikTok->>TikTok: Queue pull
Note over TikTok,Proxy: Minutes pass. TikTok pulls the media.
TikTok->>Proxy: GET /api/media?file=...&user=...&expires=...&sig=...
Proxy->>Proxy: 1. Verify HMAC signature (timingSafeEqual)
Proxy->>Proxy: 2. Check expiry (410 Gone if expired)
Proxy->>Proxy: 3. Check path startsWith user/ (403 if not)
Proxy->>Proxy: 4. Block ".." and "//" (400 if traversal)
Proxy->>SB: createSignedUrl(path, 600s TTL)
SB-->>Proxy: Signed URL
Proxy->>Proxy: Fetch file from signed URL
Proxy-->>TikTok: Stream body (Cache-Control: private, no-store)
HMAC details:
- Algorithm: SHA-256
- Secret:
MEDIA_PROXY_HMAC_SECRETenv var (64 hex chars) - Payload:
${principalId}:${mediaPath}:${expiresAt}(colon-separated) - Expiry window: 30 minutes from URL creation
- Signature comparison:
crypto.timingSafeEqual(constant-time, prevents timing attacks) - Response: proxy streams the file body directly (no redirect).
Cache-Control: private, no-store.
Stripe webhooks are verified using the Stripe SDK's built-in signature check against STRIPE_WEBHOOK_SECRET. Processed events are recorded in the stripe_webhook_events table (append-only) for idempotency. If an event ID already exists, the handler returns early without reprocessing.
TikTok webhooks are verified with HMAC-SHA256. The signed payload is ${timestamp}.${rawBody}, where the timestamp comes from the request header. Verification enforces a 300-second tolerance window to reject stale or replayed requests. An event is recorded in the tiktok_webhook_events table (append-only) only after it reached Inngest, and it is sent with its derived event id as the Inngest event id. A failed dispatch answers 500, so TikTok redelivers it instead of the event being dropped, and a redelivery never starts a second run.
Clerk webhooks are verified using the Svix library's signature verification. This validates the webhook signature, timestamp, and payload integrity.
User-created webhook subscriptions receive HMAC-SHA256 signed deliveries. Each subscription has a unique secret (generated at creation via secretGenerator.ts).
- Algorithm: HMAC-SHA256
- Payload signed: the full JSON body string
- Header:
X-Sharetopus-Signature: sha256=<hex digest> - Additional headers:
X-Sharetopus-Event(event type),X-Sharetopus-Delivery(delivery UUID),User-Agent: Sharetopus-Webhook/1.0 - Delivery timeout: 10 seconds
- Retries: 3 (via Inngest backoff) for retryable failures (5xx, 408, 429, network errors)
- Auto-disable: subscriptions are disabled after 10 consecutive failures (
AUTO_DISABLE_THRESHOLDindeliverWebhook.ts) - Re-enable: PATCH
/api/v1/webhooks/:idwith{"active": true}resetsfailure_countto 0
Verification: recipients should compute HMAC-SHA256(rawBody, subscription_secret) and compare with constant-time comparison against the sha256= value in the X-Sharetopus-Signature header.
Eight tables are append-only. Five of them (mcp_audit_log, stripe_invoices, x402_access_log, x402_refunds, sanctions_screenings) have a trigger, so Postgres itself refuses an UPDATE or DELETE. It makes three exceptions:
- a retention DELETE that opts in;
- a foreign key's ON DELETE SET NULL, which may only null that key, so deleting a user detaches their rows instead of failing;
- on
stripe_invoices, a failed invoice turning succeeded once paid.
The trigger functions are listed in DATABASE.md. The other three tables are append-only by convention: no code updates them.
| Table | Purpose | Retention |
|---|---|---|
mcp_audit_log |
Every MCP tool call with redacted args, result status, latency | 90 days (daily cron; its DELETE opts in through app.allow_append_only_delete) |
rest_audit_log |
Every REST API request with endpoint, method, status code, latency | 90 days (daily cron) |
stripe_invoices |
Payment records | Indefinite |
stripe_webhook_events |
Stripe webhook idempotency | 90 days (cleanup cron) |
tiktok_webhook_events |
TikTok webhook idempotency | 90 days (daily cron) |
x402_access_log |
Access audit trail (x402, deferred) | 90 days (daily cron; its DELETE opts in through app.allow_append_only_delete) |
x402_refunds |
Refund records (x402, deferred) | Indefinite |
sanctions_screenings |
Wallet sanctions check results (x402, deferred) | Indefinite |
The mcp_audit_log insert happens in logToolCall, which is awaited after every tool call. Arguments are redacted before insert:
- 12 key patterns replaced with
[REDACTED]: token, password, secret, authorization, bearer, api_key, apikey, access_token, refresh_token, credential, private_key, jwt. - JWT detector replaces strings matching the three-segment base64url pattern (
xxx.yyy.zzz) with[REDACTED_JWT]. - Truncation to 4096 chars after redaction.
Raw client IPs are never stored. All IP addresses are hashed before persistence.
- Algorithm: SHA-256
- Input:
ip + ":" + salt - Output: truncated to 32 hex chars
- Salt:
MCP_IP_HASH_SALTenv var, required in production (server throws if missing) - Development: fallback salt with a warning log
Three kinds of stored value would let a reader act as a user. social_accounts.access_token and refresh_token hold every posting credential: the OAuth tokens and the API keys of credentials providers. webhook_subscriptions.secret signs every webhook delivery, so it can forge one. share_links.token lets anyone connect an account for the link's owner. The application encrypts all of them with AES-256-GCM before they reach Postgres (src/lib/crypto/tokenEncryption.ts, applied by the encryptedText column type in src/db/schema.ts), so a database dump, a leaked service-role key or an SQL injection yields ciphertext only.
- Key:
SOCIAL_TOKEN_ENCRYPTION_KEY, 32 random bytes in base64, set in the deployment and never stored in the database. Without it, every query that reads or writes a token fails instead of storing plaintext. - Format:
enc:v1:<iv>:<ciphertext>:<tag>, with a fresh 96-bit IV per write. Thev1tag leaves room for a key rotation. - Tampering: a modified value or the wrong key fails the GCM tag check, and the query fails.
- Older rows: values written before encryption shipped read back unchanged. The
encrypt-social-tokensInngest job, which covers all three tables, (daily at 09:00 UTC, or on demand with thesocial-tokens.encryptevent) rewrites them inside the deployment, so the backfill always uses the deployment's key. - No value comparisons: each write uses a new IV, so SQL cannot compare these columns to a value. A guard compares another column instead (
handleRejectedRefreshguards ontoken_expires_at), and code that must see the stored text selects it through ansqltemplate. Share links are looked up byshare_links.token_hash, the SHA-256 hex of the token (hashToken), which is unique; the encrypted token stays so the owner can copy the link again. - Losing the key makes every stored token and secret unreadable: every account would need a reconnect, every webhook subscription a recreate, and every share link a new link. Keep a copy in the team's secret store.
Inngest stores every step result in the run's history, outside our database. No step returns an OAuth token or an account row that carries one: the publish workers load the account inside the step that uses it (fetchAccountForPublish), and the TikTok status poll resolves its token inside the status-check step.
Token, password, secret, and JWT patterns are redacted before insert (see Argument redaction above).
| Data | Retention |
|---|---|
mcp_audit_log, x402_access_log |
90 days, enforced since 2026-09-24 by daily crons that opt in to the append-only trigger's one DELETE exception (DATABASE.md) |
rest_audit_log |
90 days (cleanup-rest-audit-log, since 2026-09-24) |
stripe_webhook_events |
90 days (via cleanup-stripe-webhook-events cron) |
| Cancelled scheduled posts | 7-day grace period |
stripe_invoices |
Grows indefinitely (no cleanup) |
content_history |
Grows indefinitely (no cleanup) |
MCP clients send a clientInfo object (in the 2025-era initialize handshake, or in the _meta envelope of every 2026-07-28 request). Its name is stored as mcp_oauth_clients.client_name when an OAuth client is first seen, and rendered in the admin dashboard.
sanitizeClientField strips:
- ASCII control characters (0x00-0x1f)
- HTML injection characters:
<,>,',",&
This prevents stored-XSS when rendering client names in the dashboard. Field length limit: clientName max 200 chars.
These are acknowledged design decisions or low-severity issues, not bugs.
| # | Gap | Severity | Notes |
|---|---|---|---|
| 1 | Cancelled scheduled_posts hold storage indefinitely |
Design decision | Orphan sweep only catches unreferenced files. Cancelled posts still reference their media. |
| 2 | No alerting on orphan sweep counts | Low | Sweep logs stats to Inngest but no external notification hook. |
| 3 | No rate limit on the media proxy endpoint | Low | It requires a valid HMAC signature, so abuse needs a signed URL. |
| 4 | No aggregate daily cap on attach_media_from_url |
Low | Per-minute (10/60s) and per-month caps exist. A sustained 10/min attack over 24h would hit the monthly cap within a day for Creator users. |
- PII redaction in audit logs. Token, password, secret, JWT patterns are redacted before insert.
- IP hashing. Raw client IPs are never stored. SHA-256 hashed with configurable salt.
- Append-only financial tables. At the DB layer,
stripe_invoicesrows cannot be deleted. The only allowed updates are a failed invoice turning succeeded anduser_idbeing nulled when its user is deleted. - 90-day log retention. Daily crons delete
stripe_webhook_events,mcp_audit_log,x402_access_logandrest_audit_logrows older than 90 days. The append-only trigger still refuses any other UPDATE or DELETE on its tables.
- OFAC / FINTRAC / MiCA screening. The
sanctions_screeningstable exists but no screening service is integrated. - Wallet KYC. No identity verification for wallet-based access.
- USDC fair market value tracking. Not built. The empty
usdc_fmv_dailytable planned for it was dropped on 2026-09-24.
| File | Role |
|---|---|
src/lib/mcp/tools/schedulePost.ts |
Schedule post with idempotency |
src/lib/mcp/tools/postNow.ts |
Immediate post with idempotency |
src/lib/mcp/tools/bulkPostNow.ts |
Bulk immediate post with derived idempotency keys |
src/lib/mcp/tools/bulkSchedule.ts |
Bulk schedule with derived idempotency keys |
src/lib/mcp/tools/attachMediaFromUrl.ts |
URL-based media attach (rate limited, SSRF guarded) |
src/lib/mcp/tools/requestUploadUrl.ts |
Signed upload URL generation (rate limited) |
src/lib/mcp/tools/generatePostDraft.ts |
AI draft generation (monthly capped) |
src/lib/mcp/_shared/safeUserFetch.ts |
SSRF guard (IP blocklist, scheme, redirect, byte counter) |
src/lib/mcp/_shared/enforceStorageQuota.ts |
Storage quota enforcement for all upload paths |
src/lib/mcp/_shared/currentQuotaPeriod.ts |
Monthly quota period key generation |
src/lib/mcp/entitlement.ts |
Plan tier checks, monthly cap enforcement via atomic_increment_quota |
src/lib/mcp/audit.ts |
Audit logging with arg redaction and JWT detection |
src/lib/mcp/ipHash.ts |
IP hashing (SHA-256 with salt) |
src/lib/api/tiktok/buildProxiedTikTokMediaUrl.ts |
HMAC-signed TikTok media URL builder |
src/app/api/media/route.ts |
Media proxy (HMAC verification, path traversal guard) |
src/app/api/storage/generate-upload-url/route.ts |
Web upload URL generation (storage quota check) |
src/lib/api/rest/middleware/withRestEndpoint.ts |
REST API endpoint wrapper (auth, validation, audit, rate limit) |
src/lib/api/rest/audit/writeRestAuditLog.ts |
REST API audit log writer |
src/lib/api/rest/webhooks/signWebhookPayload.ts |
HMAC-SHA256 signing for outbound webhooks |
src/lib/api/rest/webhooks/secretGenerator.ts |
Webhook subscription secret generation |
src/inngest/functions/deliverWebhook.ts |
Webhook delivery with retry and auto-disable |
See also: docs/AUTH.md (principal model, auth paths), docs/MCP.md (tool inventory, annotations, idempotency), docs/REST.md (REST API endpoints, withRestEndpoint HOF), docs/WEBHOOKS.md (webhook subsystem, signing, replay), docs/STORAGE.md (upload paths, orphan sweep)