Skip to content

Security: Andy00L/sharetopus

Security

docs/SECURITY.md

Security Architecture

Every security mechanism in Sharetopus, organized by the attack it stops, the code that implements the defense, and the known gaps.

Back to README

Table of Contents

Defense Layers

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
Loading

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).

Threat Model

# 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)

Identity Flow

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
Loading

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" carries apiKeyId, scopes
  • kind: "oauth" carries oauthClientId, 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.

Rate Limiting

Per-request limits

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

Monthly caps (per-tier, atomic)

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().

Idempotency

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)
Loading

Supported tools

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.

Dispatcher-level dedup

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.

SSRF Guard

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
Loading

Blocked IP ranges

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)

Additional guards

  • Scheme validation. Only http: and https: are allowed. file:, ftp:, data:, gopher: are rejected.
  • Redirect blocking. fetch is called with redirect: "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 running byteCount is compared against maxBytes on 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.

Storage Quota Enforcement

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
Loading

enforceStorageQuota is the single enforcement point for all three upload paths:

  1. Calls get_user_storage_bytes Postgres RPC with _bucket = MEDIA_BUCKET (src/lib/storage/mediaBucket.ts) and _prefix = "{principalId}/".
  2. The RPC reads storage.objects directly (no pagination, no estimation).
  3. Computes projected = currentBytes + additionalBytes.
  4. Compares against STORAGE_LIMITS[priceId] (falls back to 5 GB default).
  5. 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 HMAC Media Proxy

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)
Loading

HMAC details:

  • Algorithm: SHA-256
  • Secret: MEDIA_PROXY_HMAC_SECRET env 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.

Webhook Verification

Stripe

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

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

Clerk webhooks are verified using the Svix library's signature verification. This validates the webhook signature, timestamp, and payload integrity.

Outbound (Sharetopus webhook subscriptions)

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_THRESHOLD in deliverWebhook.ts)
  • Re-enable: PATCH /api/v1/webhooks/:id with {"active": true} resets failure_count to 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.

Append-Only Audit

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

Argument redaction

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.

Data Protection

IP hashing

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_SALT env var, required in production (server throws if missing)
  • Development: fallback salt with a warning log

Tokens and secrets at rest

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. The v1 tag 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-tokens Inngest job, which covers all three tables, (daily at 09:00 UTC, or on demand with the social-tokens.encrypt event) 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 (handleRejectedRefresh guards on token_expires_at), and code that must see the stored text selects it through an sql template. Share links are looked up by share_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.

OAuth tokens and Inngest

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.

PII in audit logs

Token, password, secret, and JWT patterns are redacted before insert (see Argument redaction above).

Data retention

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)

XSS Prevention

clientInfo sanitization

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.

Known Gaps

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.

Compliance Posture

Active

  • 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_invoices rows cannot be deleted. The only allowed updates are a failed invoice turning succeeded and user_id being nulled when its user is deleted.
  • 90-day log retention. Daily crons delete stripe_webhook_events, mcp_audit_log, x402_access_log and rest_audit_log rows older than 90 days. The append-only trigger still refuses any other UPDATE or DELETE on its tables.

Deferred (until x402 ships)

  • OFAC / FINTRAC / MiCA screening. The sanctions_screenings table 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_daily table planned for it was dropped on 2026-09-24.

Source Files Referenced

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)

Back to README

There aren't any published security advisories