A content aggregation service for syncing and storing records from across the internet.
- Node.js >= 24 (see .nvmrc)
- npm
Install dependencies:
npm installCopy .env.example to .env and fill in the values:
cp .env.example .envSee .env.example for descriptions of each variable and where to obtain them.
The app uses Drizzle ORM with a Neon serverless Postgres database.
Push the schema to Neon (useful for initial setup):
npm run db:pushGenerate a migration from schema changes:
npm run db:generateApply pending migrations:
npm run db:migrateOpen Drizzle Studio (visual database browser):
npm run db:studio| Table | Description |
|---|---|
records |
Content records with uuid, title, content, and created_at |
subscriptions |
One row per user tracking plan, status, trial dates, and Stripe IDs |
A source is a unique ingest endpoint (/api/hooks/:slug) that turns an incoming webhook into a record. The Add Source modal offers presets (Stripe, GitHub, Zapier, Apple Shortcuts) that are a plain webhook source with a provider set, which enables signature verification on every delivery — see server/utils/signatureVerifier.ts.
Request body. The delivery body must be a JSON object (send Content-Type: application/json). For GitHub sources only, its default application/x-www-form-urlencoded content type is also accepted, since GitHub URL-encodes the JSON under a payload form field and computes the signature over that raw body. Anything else — a plain-text body, a form-encoded body from a non-GitHub source, a JSON scalar/array, or an empty body — is rejected with 400 Bad Request rather than silently ingesting a blank "Untitled" record, so a misconfigured integration fails loudly (the 400 shows in the sender's delivery log) instead of quietly producing empty records. Field mapping (see below) still runs on the object, so keys can be remapped to title/body, but the top-level payload itself must be an object.
| Provider | Verification | Secret |
|---|---|---|
| Stripe | HMAC-SHA256 over the Stripe-Signature header |
User-supplied at creation — Stripe issues the signing secret when the user creates their own webhook endpoint, so the Add Source modal asks them to paste it in before the source can be created. Stored as-is (HMAC needs the raw value). Unrelated to the app's own billing webhook, which verifies against the separate STRIPE_WEBHOOK_SECRET env var (see "Billing and subscriptions" below). |
| GitHub | HMAC-SHA256 over the X-Hub-Signature-256 header |
Generated per source at creation time; paste it into the GitHub repo's Settings → Webhooks → Secret field. Stored as-is (HMAC needs the raw value). |
| Zapier | Shared secret compared via the X-Markpost-Secret header |
Generated per source at creation time; add it as a custom header on the Zapier webhook action. Only a SHA-256 hash is stored, since equality comparison never needs the plaintext back. |
| Apple Shortcuts | Shared secret compared via the X-Markpost-Secret header |
Generated per source at creation time; add it as a custom header in the "Get Contents of URL" action. Only a SHA-256 hash is stored, for the same reason as Zapier. |
A generated secret (GitHub/Zapier/Shortcuts) is revealed exactly once, in the response to the request that created the source (the Add Source modal shows a one-time "copy this now" step) — the API never returns it again on subsequent GET/PATCH calls, and the reactive source list in the app never holds onto it either. A user-supplied secret (Stripe) is never shown back, since the user already has it.
Sources created before this verification model existed (provider left null) keep working unauthenticated rather than being retroactively broken — enabling verification is opt-in for new sources, not a forced migration for old ones.
Payload size cap. Ingest rejects any delivery whose body exceeds MAX_WEBHOOK_BODY_BYTES (1 MiB) with a 413, checked by Content-Length before buffering and again on the decoded body about to be stored (see server/utils/webhookBodyLimit.ts). The rate limit caps request count, not size, so this is what stops a single oversized body from bloating storage and memory on an endpoint that only needs a slug to reach.
Rotating a secret. POST /api/sources/:uuid/rotate-secret rotates a leaked or lost secret in place — the endpointSlug is preserved, so the provider's existing webhook URL keeps working and only the secret has to be re-pasted. It never changes the source's provider; PATCH /api/sources/:uuid still deliberately ignores provider/providerSecret (that endpoint is for routeFolder/fieldMapping only). Behaviour mirrors source creation per provider:
- GitHub — generates a new HMAC secret and reveals the plaintext exactly once in the response (paste it into the GitHub webhook's Secret field). Sending a
providerSecretis rejected — the value is server-generated. - Zapier / Apple Shortcuts — generates a new shared secret, stores only its SHA-256 hash, and reveals the plaintext exactly once (update the custom header on that provider). Sending a
providerSecretis rejected. - Stripe — the caller supplies the new signing secret in
data.attributes.providerSecret(Stripe issues it when the endpoint is rotated on Stripe's side). It is stored as-is and never echoed back, since the caller already has it.
A source with no provider set has no rotatable secret, so the endpoint returns 422. As with creation, a generated secret is revealed only in the rotate response itself — never again on GET/PATCH.
Rotation takes effect immediately and there is no overlap window: the old secret stops verifying the moment the rotation commits, so any delivery that arrives before you paste the new secret into the provider fails signature verification (GitHub does not retry those). Paste the revealed secret into the provider right away. A dual-secret grace window that keeps the previous secret valid for a few minutes is a reasonable follow-up if this gap becomes a problem. The write uses optimistic concurrency (gated on the secret it just read), so two racing rotations can't both "win" — the loser gets a 409 and should retry rather than pasting a secret that was never stored.
RSS/Atom is listed as a preset but marked unavailable in the UI: there is no polling infrastructure (scheduler, dedup, fetch cadence) to back it yet.
Billing is handled via Stripe. The integration consists of three API routes under /api/billing/:
| Route | Method | Auth | Description |
|---|---|---|---|
/api/billing/checkout |
POST | Clerk / API token | Creates a Stripe Checkout session for upgrading to Pro. Returns { data: { url } } — redirect the user to this URL. |
/api/billing/portal |
POST | Clerk / API token | Creates a Stripe Customer Portal session for managing an existing subscription. Returns { data: { url } }. Requires the user to have an existing Stripe customer ID (i.e. they have completed at least one Checkout session). |
/api/billing/webhook |
POST | None (Stripe signature) | Receives Stripe lifecycle events (customer.subscription.created/updated/deleted, checkout.session.completed) and updates the local subscriptions table. The Stripe-Signature header is verified on every request using STRIPE_WEBHOOK_SECRET. |
/api/billing/usage |
GET | Clerk / API token | Returns the number of records created this month (the same metric the Hobby monthly cap enforces) and the number of connected sources. |
| Variable | Description |
|---|---|
STRIPE_SECRET_KEY |
Stripe secret key (from Dashboard → API Keys). Server-only. A standard secret key covers this; a restricted key must grant customers read in addition to subscriptions read/write, since account deletion reads the customer to prove the key can see it before treating a subscription sweep as complete. |
STRIPE_WEBHOOK_SECRET |
Webhook signing secret (from Dashboard → Webhooks → your endpoint → Signing secret). |
STRIPE_PRO_PRICE_ID |
Stripe price ID for the monthly Pro plan. |
STRIPE_PRO_ANNUAL_PRICE_ID |
Stripe price ID for the annual Pro plan (optional; falls back to monthly). |
- Go to Stripe Webhooks and add an endpoint pointing to
https://your-domain.com/api/billing/webhook. - Subscribe to:
customer.subscription.created,customer.subscription.updated,customer.subscription.deleted,checkout.session.completed. - Copy the signing secret into
STRIPE_WEBHOOK_SECRET.
For local development, use the Stripe CLI:
stripe listen --forward-to http://localhost:3000/api/billing/webhookAuthentication is handled by Clerk via the @clerk/nuxt module. Server middleware at server/middleware/auth.ts verifies the session on every request and makes the user available at event.context.userId in API route handlers.
Deleting an account in-app (DELETE /api/account) cancels Stripe billing and cascade-deletes the user's data. A user removed out-of-band — from the Clerk Dashboard or Account Portal — bypasses that path, which would orphan their DB rows and leave live Stripe billing running. To reconcile it, POST /api/webhooks/clerk receives Clerk's user.deleted event and runs the same cancel-Stripe-then-wipe-data teardown (shared logic lives in server/services/accountDeletion.ts). The Svix signature on every delivery is verified against CLERK_WEBHOOK_SIGNING_SECRET; an invalid or missing signature is rejected with a 400 before any teardown runs. Unrecognised event types are acknowledged with a 200 no-op.
To set it up: in the Clerk Dashboard → Configure → Webhooks, add an endpoint pointing to https://your-domain.com/api/webhooks/clerk, subscribe it to the user.deleted event, and copy the endpoint's Signing Secret into CLERK_WEBHOOK_SIGNING_SECRET.
| Env var | Where to get it |
|---|---|
CLERK_WEBHOOK_SIGNING_SECRET |
Signing secret for the Clerk webhook endpoint (Dashboard → Configure → Webhooks → your endpoint → Signing Secret). |
Markpost publishes a set of predictable, machine-readable resources so that AI agents and other automated clients can discover the product and its API without scraping the UI:
public/openapi.json— the full HTTP API surface as an OpenAPI 3.1 document, served at/openapi.json. GlobalbearerAuthsecurity, with anx-scopescatalog of the named permission scopes.server/utils/protectedResource.ts— builds RFC 9728 protected-resource metadata served at/.well-known/oauth-protected-resource, declaringscopes_supportedfor the API. Emitted byserver/middleware/agentContent.ts.public/llms.txt— an llmstxt.org index pointing agents at the docs, OpenAPI spec, protected-resource metadata, and Markdown representations.public/sitemap.xmlandpublic/robots.txt— public URL discovery.- Markdown content negotiation —
server/middleware/agentContent.tsserves atext/markdownrepresentation of the home, docs, and pricing pages when requested withAccept: text/markdown(or a.mdsuffix), and addsVary: Accepton those routes. Unknown paths return a real HTTP 404 with a short Markdown body instead of a soft-200 app shell.
The scopes declared in the OpenAPI x-scopes catalog and the protected-resource metadata are advertised for discovery but not yet enforced per-endpoint — tokens currently grant full access. See server/utils/agentContent.ts and server/utils/protectedResource.ts for the source of truth.
Start the dev server at http://localhost:3000:
npm run devRun unit tests in watch mode:
npm testRun once (CI mode):
npm run test:ciRun end-to-end tests (requires .env.e2e):
npm run e2eThe suite signs in using Clerk's test-email flow. A dedicated test user is provisioned automatically via the Clerk Backend API (using NUXT_CLERK_SECRET_KEY), so no separate Clerk account or credentials are needed.
API requests live in postman/ in Postman's multi-file (Git-integrated) format. Each request, environment, and the workspace globals is a separate YAML file under postman/collections/, postman/environments/, and postman/globals/. This is the format the Postman VS Code extension reads directly — it is not a single .postman_collection.json file that the desktop app's File → Import dialog can open.
Use the Postman VS Code extension:
- Install the Postman extension in VS Code and sign in.
- Open this repository as a folder in VS Code.
- In the Postman panel, the
apicollection underpostman/collections/api/is detected automatically. Pick theLocalorProductionenvironment (frompostman/environments/) and send requests.
The apiToken workspace global lives in postman/globals/workspace.globals.yaml.
The collection uses bearer token auth via the {{apiToken}} workspace global. Two environments are included — Local and Production — that control baseUrl:
| Variable | Location | Description |
|---|---|---|
baseUrl |
Environment | Base URL for the API (http://localhost:3000 for Local; production URL for Production) |
apiToken |
Workspace globals | Clerk session JWT sent in Authorization: Bearer <token> — obtain from Clerk Dashboard → your user → Sessions → copy the session access token, or retrieve it in-app via await session.getToken() |
Fill in apiToken in your workspace globals directly in your Postman client (it is intentionally left blank in the repo). The value is validated server-side by clerkClient.verifyToken() in server/middleware/auth.ts — it must be a valid Clerk-issued JWT, not the server secret key.
Note: The Production environment's
baseUrlis currently a placeholder (https://markpost.example.com). Update it to the actual deployed URL once known.
Check for issues:
npm run lintAuto-fix:
npm run lint:fixA deterministic scanner layer guards against committed secrets and vulnerable dependencies, both locally and in CI.
Secret scanning (gitleaks)
Rules live in .gitleaks.toml, which extends the default gitleaks ruleset with checks for Clerk secret keys (sk_live_/sk_test_) and credentialed Postgres connection strings. Publishable Clerk keys (pk_*) are public by design and are not flagged.
-
Locally: the
.husky/pre-commithook scans staged changes and blocks the commit on any finding. Install gitleaks first (instructions); if it is not installed, the hook prints a warning and lets the commit through. -
Run a manual staged scan:
gitleaks git --staged --redact --verbose --config .gitleaks.toml
-
In CI: the
secret-scanjob in.github/workflows/security.ymldownloads the pinned gitleaks binary, scans the PR commit range on pull requests, and scans full history on push tomain. It fails the check on any finding.
- In CI: the
dependency-auditjob in.github/workflows/security.ymlrunsnpm audit. It fails only on high or critical advisories and prints moderate/low advisories as a summary. - Automated updates:
.github/dependabot.ymlopens weekly PRs againstmain, grouping minor and patch bumps into a single PR.
npm run build
npm run previewThe app deploys to Netlify automatically on push to main. CI runs lint and unit tests before the build. E2e tests run as a separate job after CI passes.
Required repository secrets (Settings → Secrets → Actions):
E2E_DATABASE_URLNUXT_PUBLIC_CLERK_PUBLISHABLE_KEYNUXT_CLERK_SECRET_KEYCLERK_WEBHOOK_SIGNING_SECRETSENTRY_AUTH_TOKENSENTRY_DSNSENTRY_ORGSENTRY_PROJECTSTRIPE_SECRET_KEYSTRIPE_WEBHOOK_SECRETSTRIPE_PRO_PRICE_IDSTRIPE_PRO_ANNUAL_PRICE_ID(optional)