Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
43 changes: 24 additions & 19 deletions .agents/skills/jobs-define-job/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,17 @@ import * as z from "zod/v4";
import { defineJob } from "../index";

const inputSchema = z.object({
tagId: z.string(),
programId: z.string(),
partnerId: z.string(),
});

export const linkTagDeletedJob = defineJob({
name: "link-tag-deleted-job",
export const unbanPartnerJob = defineJob({
name: "unban-partner-job",
schema: inputSchema,
defaults: {
retries: 3, // optional; QStash retries on 5xx
// queue: "link-tag-deleted", // optional; named QStash queue
// flowControl: { key: "link-tag-deleted", parallelism: 20 },
// queue: "unban-partner", // optional; named QStash queue
// flowControl: { key: "unban-partner", parallelism: 20 },
},
async handle(input) {
// skip (permanent / not found) → return (process route returns 2xx, QStash does not retry)
Expand All @@ -51,16 +52,17 @@ Export the const as camelCase `{name}Job` matching the kebab `name`.

Reference handlers:

- Simple skip/work: `welcome-user-job.ts`, `process-shopify-order-job.ts`
- Self-pagination: `folder-deleted-job.ts`, `domain-deleted-job.ts`, `link-tag-deleted-job.ts`
- Simple skip/work: `unban-partner-job.ts`, `create-tremendous-campaign-job.ts`
- Self-pagination: `folder-deleted-job.ts`, `domain-deleted-job.ts`, `partner-search-sync-job.ts`
- `defaults.flowControl`: `partner-search-sync-job.ts`

## 2. Register it

Add a **static** `import()` in [`apps/web/lib/jobs/registry.ts`](apps/web/lib/jobs/registry.ts) `jobLoaders`. The object key must equal `defineJob({ name })`. Webpack code-splits each handler.

```ts
"link-tag-deleted-job": () =>
import("./handlers/link-tag-deleted-job").then((m) => m.linkTagDeletedJob),
"unban-partner-job": () =>
import("./handlers/unban-partner-job").then((m) => m.unbanPartnerJob),
```

Do not register by editing the process route. `loadJob` throws if `job.name !==` the registry key.
Expand All @@ -70,9 +72,12 @@ Do not register by editing the process route. `loadJob` throws if `job.name !==`
Import the handler (not `qstash`) and call `dispatch` / `dispatchBatch`:

```ts
import { linkTagDeletedJob } from "@/lib/jobs/handlers/link-tag-deleted-job";
import { unbanPartnerJob } from "@/lib/jobs/handlers/unban-partner-job";

await linkTagDeletedJob.dispatch({ tagId }, { label: tagId });
await unbanPartnerJob.dispatch(
{ workspaceId, programId, partnerId },
{ label: partnerId },
);

await folderDeletedJob.dispatchBatch(
folderIds.map((folderId) => ({ folderId })),
Expand All @@ -82,9 +87,9 @@ await folderDeletedJob.dispatchBatch(

Per-dispatch options merge over `defaults`: `delay`, `notBefore`, `deduplicationId`, `retries`, `queue`, `flowControl`, `label`.

Failed QStash publish is persisted to the jobs outbox automatically — do not catch-and-swallow unless even the outbox persist failing must not fail the source mutation.
Failed QStash publish is persisted to the jobs outbox automatically — do not catch-and-swallow unless even the outbox persist failing must not fail the source mutation (see `queue-partner-search-sync.ts`).

Self-pagination: call `theJob.dispatch(nextPayload, { delay: 1 })` from `handle` (see `folder-deleted-job`, `link-tag-deleted-job`).
Self-pagination: call `theJob.dispatch(nextPayload, { delay: 1 })` from `handle` (see `folder-deleted-job`, `partner-search-sync-job`).

Do not call `job.execute` from app code. That is only for the process route (and cron drain shims below).

Expand All @@ -97,12 +102,12 @@ Do not call `job.execute` from app code. That is only for the process route (and

## Handle semantics

| Outcome | What to do | HTTP from process route | QStash |
| -------------------------------------------------- | ------------------------------- | ----------------------- | -------------------- |
| Work done | `return` | 200 | stop |
| Skip (not found, already done, env not configured) | `console.*` + `return` | 200 | stop |
| Bad payload | throw `ZodError` (schema.parse) | 200 | stop (non-retryable) |
| Transient failure | `throw` | 500 | retry |
| Outcome | What to do | HTTP from process route | QStash |
| --- | --- | --- | --- |
| Work done | `return` | 200 | stop |
| Skip (not found, already done, env not configured) | `console.*` + `return` | 200 | stop |
| Bad payload | throw `ZodError` (schema.parse) | 200 | stop (non-retryable) |
| Transient failure | `throw` | 500 | retry |

Unknown job names and invalid envelopes also return 2xx so QStash does not retry forever.

Expand Down
38 changes: 19 additions & 19 deletions .agents/skills/playwright-api-tests/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ description: Add or extend Playwright HTTP API specs under apps/web/playwright/a

HTTP API coverage lives in `apps/web/playwright/api/<resource>/*.spec.ts`. Auth is seeded once by Playwright `globalSetup` (`apps/web/global-setup.ts` → `setup-test-workspace.ts`) into `playwright/.auth/api.json`. Specs use the custom `test` from `../fixtures` — not `@playwright/test` directly for `test`.

Do **not** put these under `apps/web/tests/` (Vitest) or under `playwright/workspaces` (browser e2e).
Do **not** put these under `apps/web/tests/` (Vitest) or under `playwright/partners` / `playwright/workspaces` (browser e2e).

## Before writing

Expand Down Expand Up @@ -91,32 +91,32 @@ test("POST /things", async ({ api }) => {

## Required conventions

| Rule | Detail |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Import `test` from `../fixtures` | Provides `api` and `workspace` |
| Serial only when tests share state | API project is `fullyParallel: true`. Do not add `mode: "parallel"`. Use `test.describe.configure({ mode: "serial" })` only when tests in a file/describe share state (e.g. domains, seeded pagination) |
| Cleanup in `finally` | Create → assert → always delete created rows |
| Unique names/ids | Use `randomName` / `randomCustomer` from `../../utils` — never fixed colliding names |
| Assert status + body | Prefer `toStrictEqual` / `toEqual` on full shapes; use `expect.any(String)` for ids/timestamps |
| Default shape once | Happy-path POST owns the full default resource (and nested) shape. Variant tests assert only what they change |
| HTTP contract only | Assert status + JSON. Do not poll/sleep for `waitUntil`, R2, or other background jobs — CI has no `STORAGE_*` |
| Error responses | Match `{ error: { code, message, doc_url } }` exactly |
| Typed generics | `api.get<T>`, `api.post<T>`, etc. |
| Seeded fixtures | `{ workspace }`, `TEST_WORKSPACE` — not Vitest `E2E_*` constants |

### Fixtures (`api` / `workspace`)
| Rule | Detail |
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| Import `test` from `../fixtures` | Provides `api`, `workspace`, and `program` |
| Serial only when tests share state | API project is `fullyParallel: true`. Do not add `mode: "parallel"`. Use `test.describe.configure({ mode: "serial" })` only when tests in a file/describe share state (e.g. domains, seeded pagination) |
| Cleanup in `finally` | Create → assert → always delete created rows |
| Unique names/ids | Use `randomName` / `randomCustomer` / `randomPartnerEmail` from `../../utils` — never fixed colliding names |
| Assert status + body | Prefer `toStrictEqual` / `toEqual` on full shapes; use `expect.any(String)` for ids/timestamps |
| Default shape once | Happy-path POST owns the full default resource (and nested) shape. Variant tests assert only what they change |
| HTTP contract only | Assert status + JSON. Do not poll/sleep for `waitUntil`, R2, or other background jobs — CI has no `STORAGE_*` |
| Error responses | Match `{ error: { code, message, doc_url } }` exactly |
| Typed generics | `api.get<T>`, `api.post<T>`, etc. |
| Seeded fixtures | `{ workspace }`, `{ program }` (`id`, `defaultGroupId`), `TEST_WORKSPACE` — not Vitest `E2E_*` constants |

### Fixtures (`api` / `workspace` / `program`)

- `api.get/post/patch/delete(url, data?)` → `{ status, data }` (JSON parsed).
- Paths are app-relative (`/api/...`); `baseURL` is `http://localhost:8888`.
- `workspace.id` / `workspace.slug` come from `.auth/api.json`.
- Seeded link domain: `TEST_WORKSPACE.domain` in `setup-test-workspace.ts`.
- `workspace.id` / `workspace.slug` and `program.id` / `program.defaultGroupId` come from `.auth/api.json`.
- Seeded program domain/url: `TEST_WORKSPACE.program` in `setup-test-workspace.ts`.

### Helpers

- Shared randomizers / sort assertions: `apps/web/playwright/utils.ts`.
- Local `createX` / `deleteX` helpers in the spec file when the same setup repeats.
- Once `createX` exists, use it for setup **and** follow-up creates (pass overrides for identity fields that must stay stable, e.g. `email`). Happy-path tests may still inline `api.post` so the asserted body is local.
- Prisma direct seed is allowed for bulk fixtures (see `customers/customers-pagination.spec.ts`); still clean up in `finally` / after hooks. No DELETE route → Prisma/`conn` cleanup is fine.
- Prisma direct seed is allowed for bulk fixtures (see `customers/customers-pagination.spec.ts`); still clean up in `finally` / after hooks. No DELETE route → Prisma/`conn` cleanup is fine (`partners/partners.spec.ts`).

### Error / table-driven cases

Expand Down Expand Up @@ -154,7 +154,7 @@ When converting `apps/web/tests/<resource>/*.test.ts`:

1. Add or extend `playwright/api/<resource>/<resource>.spec.ts` — do not leave a parallel Vitest HTTP spec.
2. Map `IntegrationHarness` / `http.post({ path })` → `api` fixture (`/api/...` paths).
3. Replace `E2E_*` constants with `{ workspace }` and `TEST_WORKSPACE`.
3. Replace `E2E_*` / `E2E_PARTNER_GROUP` with `{ workspace }`, `{ program }`, and `TEST_WORKSPACE`.
4. Delete the Vitest file after the Playwright spec covers its cases.

## Do not
Expand Down
20 changes: 20 additions & 0 deletions .deepsec/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Credentials and the dedicated Vercel project link
.env.local
.vercel/

# Reproducible scan state (INFO.md stays tracked)
data/*/setup/
data/*/files/
data/*/runs/
data/*/reports/
data/*/project.json
data/*/tech.json

# Dependencies
node_modules/

# Local / CI exports
findings/
findings.json
comment.md
pr-files.txt
1 change: 1 addition & 0 deletions .deepsec/.npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ignore-workspace=true
42 changes: 42 additions & 0 deletions .deepsec/data/web/INFO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Dub web (`apps/web`)

Dub is a multi-tenant link-management and partner/affiliate platform. This DeepSec project covers only `apps/web` (Next.js App Router). Do not treat `packages/*` as in-scope unless a web file imports a helper that clearly implements authz or money movement.

## Product surfaces

- **Workspaces** are the tenant boundary. Almost every customer API is workspace-scoped (`/api/workspaces/[idOrSlug]/…` or a workspace resolved from the session/token).
- **Links, domains, folders, analytics** are the core product.
- **Partners / programs** (enterprise): commissions, bounties, applications, network, partner-profile APIs under `app/(ee)/`.
- **Payouts**: Stripe and PayPal withdrawals, commission aggregation, force-withdrawals. Money-movement bugs are HIGH/CRITICAL until proven otherwise.
- **Admin portal**: internal Dub-staff tools gated by `withAdmin` (membership on the Dub workspace). Impersonation lives here.
- **OAuth / SAML / embed**: third-party apps and partner embeds. Confused-deputy and redirect issues matter.
- **Crons**: QStash-signed routes under `app/(ee)/api/cron/`. Missing or weak signature checks are critical.

## Auth primitives (expected)

- `withWorkspace` (`lib/auth/workspace.ts`): session cookie **or** hashed API token. Enforces workspace membership, role, token scopes, plan, and optional feature flags. This is the default customer API wrapper.
- `withSession` (`lib/auth/session.ts`): authenticated user, not necessarily a workspace. Used for user-level routes.
- `withPartnerProfile` (`lib/auth/partner.ts`): partner-user session/token plus `throwIfNoPermission`.
- `withAdmin` (`lib/auth/admin.ts`): Dub staff only (`DUB_WORKSPACE_ID`).
- `withPublishableKey` (`lib/auth/publishable-key.ts`): `dub_pk_…` public keys. Must stay read-scoped; never treat as a secret API token.
- Partner-user permissions: `lib/auth/partner-users/`.
- Tokens are hashed (`hashToken`) and cached (`tokenCache`). Compare hashes, not raw tokens.

A route that reads `workspaceId` / `programId` / `partnerId` / `commissionId` from the URL or body without going through one of these wrappers (or an equivalent explicit check) is a likely IDOR.

## Common false-positive sources

- `(ee)` folders are feature/plan gated, not “unauthenticated.”
- Plan checks (`requiredPlan`) and token scopes (`requiredPermissions`) are intentional 403s, not missing auth.
- Publishable-key routes are supposed to be limited; flag them only if they mutate or leak other workspaces.
- QStash crons are authenticated by signature (`verifyQstashSignature`), not by `withWorkspace`.
- Test, Playwright, and script files are out of scope.

## What to prioritize

1. Cross-workspace IDOR (read/write another workspace’s links, customers, payouts, or programs).
2. Authz gaps: member/token doing owner-only actions; partner users exceeding role permissions.
3. Payout / commission / withdrawal tampering (amount, destination, status).
4. SSRF from user-controlled URLs (webhooks, oEmbed, domain verification, imports).
5. XSS in dashboards, embeds, and OG/preview HTML.
6. Cron or webhook handlers that skip signature verification.
14 changes: 14 additions & 0 deletions .deepsec/data/web/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"ignorePaths": [
"playwright-report/**",
"test-results/**",
"playwright/**",
"tests/**",
"**/.env",
"**/.env.*",
"**/.env.example",
".next/**",
"scripts/**",
"public/**"
]
}
24 changes: 24 additions & 0 deletions .deepsec/deepsec.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { defineConfig } from "deepsec/config";

export default defineConfig({
ai: { mode: "gateway", provider: "vercel" },
defaultAgent: "codex",
projects: [
{
id: "web",
root: "../apps/web",
githubUrl: "https://github.com/dubinc/dub/blob/main/apps/web",
priorityPaths: [
"lib/auth/",
"app/api/",
"app/(ee)/api/payouts/",
"app/(ee)/api/partners/",
"app/(ee)/api/admin/",
"app/(ee)/api/oauth/",
"app/(ee)/api/cron/",
],
promptAppend:
"Focus on IDOR/tenant isolation, authz gaps, SSRF, XSS, and payout/money-movement bugs. Ignore style and tests.",
},
],
});
12 changes: 12 additions & 0 deletions .deepsec/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "deepsec-workspace",
"private": true,
"type": "module",
"packageManager": "pnpm@9.15.9",
"scripts": {
"deepsec": "deepsec"
},
"dependencies": {
"deepsec": "^2.3.8"
}
}
Loading
Loading