Skip to content

0xanshu feat/multiple projects system#89

Open
SteakFisher wants to merge 12 commits into
mainfrom
0xanshu-feat/multiple-projects-system
Open

0xanshu feat/multiple projects system#89
SteakFisher wants to merge 12 commits into
mainfrom
0xanshu-feat/multiple-projects-system

Conversation

@SteakFisher

Copy link
Copy Markdown
Member

No description provided.

@greptile-apps

greptile-apps Bot commented Jun 16, 2026

Copy link
Copy Markdown

Greptile Summary

This PR introduces a multi-project system by adding a projects table and propagating a project_id FK column across every existing table, then threading that ID through authentication, route handlers, DB helpers, and the payment provider layer.

  • Schema: New projectTable; composite PK on usersTable; project_id added to api_keys, sessions, event tables, metadata, tags, expressions, webhook_endpoints, and webhook_deliveries; the previously-reported unique_active_name index is now correctly scoped to (project_id, name).
  • Security fixes: Cross-project API-key listing, revocation, and webhook endpoint manipulation reported in earlier reviews are all addressed with eq(table.project_id, auth.project_id) WHERE clauses and explicit ownership checks.
  • New endpoint: POST /api/v1/internals/projects lets a dashboard key update its project's product_id; the createProject helper uses a select-then-insert pattern that is not concurrency-safe and should be replaced with an atomic upsert.

Confidence Score: 3/5

Not safe to merge without fixing the race condition in createProject and reviewing the partial-update regression in upsertMetadata.

The multi-project security fixes are thorough and well-structured, but two new correctness issues were introduced. createProject uses a non-atomic select-then-insert that will surface as an opaque StorageError on concurrent onboarding requests. upsertMetadata added requireField guards that fire unconditionally on the insert path, making partial updates impossible even though the type signature advertises them as supported.

src/storage/db/postgres/helpers/projects.ts (TOCTOU insert) and src/storage/db/postgres/helpers/metadata.ts (requireField blocking update-only calls) need attention before this ships.

Important Files Changed

Filename Overview
src/storage/db/postgres/helpers/projects.ts New helper for the projects table; contains a TOCTOU race in createProject — the select-then-insert pattern can throw a PK violation under concurrent access instead of safely upserting.
src/storage/db/postgres/helpers/metadata.ts Reworked to project-scoped upsert using onConflictDoUpdate; requireField guards break the partial-update contract advertised by the optional-typed UpsertMetadataInput fields.
src/storage/db/postgres/schema.ts Adds projectTable and project_id FK column to every existing table; fixes unique_active_name index to be scoped by (project_id, name); converts usersTable to a composite PK (id, project_id) with corresponding composite FK references in event tables.
src/routes/http/api/apiKeys.ts Fixes the previously-reported cross-project listing and revocation bugs by capturing the auth result and adding eq(apiKeysTable.project_id, auth.project_id) to WHERE clauses in both handleListApiKeys and handleRevokeApiKey.
src/routes/http/api/webhookEndpoints.ts Adds targetKey.project_id !== auth.project_id ownership checks in both handleCreateWebhookEndpoint and handleSendTestWebhook, closing the previously-reported cross-project webhook abuse vectors.
src/routes/http/api/handleProjects.ts New internal endpoint /api/v1/internals/projects for updating a project's product_id; correctly restricts to dashboard keys; maps all AuthError (including permissionDenied) to HTTP 401.
src/routes/http/api/onboarding.ts Now creates the project row and upserts metadata in a single transaction during onboarding; also scopes the Dodo webhook callback URLs with project_id so incoming payment webhooks can be routed to the correct project.
src/routes/gRPC/payment/paymentProvider.ts Replaces singleton liveClient/testClient with Map<project_id, DodoPayments> to support per-project payment clients; clearClients now accepts an optional project_id to invalidate only one project's clients.
src/utils/authenticateHttpApiKey.ts Now returns project_id in the auth result and validates that it is non-empty, mirroring the same guard added to the gRPC authInterceptor.
src/routes/http/registerWebhookRoutes.ts Reads project_id from the webhook callback URL query string and passes it through to handleDodoWebhook for project-scoped metadata lookup and signature verification.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant C as Client
    participant AI as Auth Interceptor / authenticateHttpApiKey
    participant Cache as API Key Cache
    participant DB as PostgreSQL
    participant RH as Route / gRPC Handler

    C->>AI: Request + Bearer token
    AI->>Cache: Lookup hashed key
    alt Cache hit
        Cache-->>AI: "{ id, role, mode, project_id }"
    else Cache miss
        AI->>DB: "SELECT id, role, mode, project_id FROM api_keys WHERE key = hash"
        DB-->>AI: API key record
        AI->>AI: Validate project_id non-empty
        AI->>Cache: "Store { id, role, mode, project_id }"
    end
    AI-->>RH: "AuthContext { apiKeyId, role, mode, project_id }"
    RH->>DB: Query scoped to project_id
    DB-->>RH: Project-scoped rows
    RH-->>C: Response
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant C as Client
    participant AI as Auth Interceptor / authenticateHttpApiKey
    participant Cache as API Key Cache
    participant DB as PostgreSQL
    participant RH as Route / gRPC Handler

    C->>AI: Request + Bearer token
    AI->>Cache: Lookup hashed key
    alt Cache hit
        Cache-->>AI: "{ id, role, mode, project_id }"
    else Cache miss
        AI->>DB: "SELECT id, role, mode, project_id FROM api_keys WHERE key = hash"
        DB-->>AI: API key record
        AI->>AI: Validate project_id non-empty
        AI->>Cache: "Store { id, role, mode, project_id }"
    end
    AI-->>RH: "AuthContext { apiKeyId, role, mode, project_id }"
    RH->>DB: Query scoped to project_id
    DB-->>RH: Project-scoped rows
    RH-->>C: Response
Loading

Reviews (2): Last reviewed commit: "fix(security): here and there" | Re-trigger Greptile

Comment on lines +17 to +31
const existing = await db
.select({ project_id: projectTable.project_id })
.from(projectTable)
.where(eq(projectTable.project_id, project_id))
.limit(1);

if (existing[0]) {
await db
.update(projectTable)
.set({ product_id })
.where(eq(projectTable.project_id, existing[0].project_id));
return;
}

await db.insert(projectTable).values({ project_id, product_id });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 TOCTOU race — concurrent inserts will throw StorageError.insertFailed

The select-then-insert pattern has no locking, so two concurrent callers that both reach line 21 with no existing row will each attempt the insert on line 31. The second insert hits a primary-key violation, which is caught and re-thrown as StorageError.insertFailed. This can be triggered during onboarding if the client retries or if two requests race. Replace the select/branch/insert with a single INSERT … ON CONFLICT (project_id) DO UPDATE SET product_id = EXCLUDED.product_id, which is atomic and idempotent.

Comment on lines +59 to +95
if (Object.keys(setValues).length === 0) return;

const insertValues: typeof metadataTable.$inferInsert = {
...setValues,
} as typeof metadataTable.$inferInsert;
await txn.insert(metadataTable).values(insertValues);
await txn
.insert(metadataTable)
.values({
project_id: input.project_id,
dodo_live_api_key: requireField(
input.dodo_live_api_key,
"dodo_live_api_key"
),
dodo_test_api_key: requireField(
input.dodo_test_api_key,
"dodo_test_api_key"
),
dodo_live_product_id: requireField(
input.dodo_live_product_id,
"dodo_live_product_id"
),
dodo_test_product_id: requireField(
input.dodo_test_product_id,
"dodo_test_product_id"
),
dodo_live_webhook_secret: requireField(
input.dodo_live_webhook_secret,
"dodo_live_webhook_secret"
),
dodo_test_webhook_secret: requireField(
input.dodo_test_webhook_secret,
"dodo_test_webhook_secret"
),
redirect_url: requireField(input.redirect_url, "redirect_url"),
currency: input.currency,
})
.onConflictDoUpdate({
target: metadataTable.project_id,
set: setValues,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 requireField gates on insert path break future partial-update callers

All requireField calls are evaluated unconditionally before the SQL is dispatched. A caller that passes only currency (to update one field on an existing row) will get a StorageError.insertFailed("Missing required field 'dodo_live_api_key'") even though the conflict branch would never need that value. The UpsertMetadataInput type marks every credential field as optional, advertising that partial updates are supported — but the runtime behavior contradicts this. Today the only caller (handleOnboarding) always supplies all fields, so nothing is broken right now. Move requireField into an explicit insert-only helper and keep setValues as the unconditional update payload.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants