Skip to content

Feat/project#91

Open
SteakFisher wants to merge 9 commits into
devfrom
feat/project
Open

Feat/project#91
SteakFisher wants to merge 9 commits into
devfrom
feat/project

Conversation

@SteakFisher

Copy link
Copy Markdown
Member

No description provided.

@greptile-apps

greptile-apps Bot commented Jun 18, 2026

Copy link
Copy Markdown

Greptile Summary

This PR introduces full multi-tenancy by adding a projects table and threading projectId through every schema table, storage helper, route, and ClickHouse query. It also replaces the single-instance onboarding flow with per-project creation, scopes API key and webhook management to dashboard-only keys, and switches the payment provider cache from two global singletons to a per-project map.

  • Schema & storage: projectsTable added; every table (users, sessions, api_keys, basic_usage_events, ai_token_usage_events, payment_events, tags, expressions, webhook_endpoints, webhook_deliveries, metadata) gains a project_id FK. All helpers and query adapters updated to scope reads and writes by project.
  • Onboarding: replaced upsertMetadata with a transactional flow that creates a project, metadata row, and a default dashboard key in one shot, returning { projectId, apiKey }. The new name field is required server-side but the dashboard's submitOnboarding does not send it, so all onboarding calls will fail with a validation error until the dashboard is updated.
  • Data query API: metadata removed from TABLE_REGISTRY to prevent exposure of sensitive config, but the scrawn.js SDK's MetadataBuilder still targets that table and will receive an unknown-table error from the server.

Confidence Score: 3/5

Not safe to merge yet — the dashboard onboarding form will be completely broken on deploy, and the SDK's metadata query will return errors for all existing consumers.

The server now enforces a required name field in the onboarding payload, but the dashboard never sends it, so every project creation attempt through the UI will fail with a validation error. Separately, removing metadata from the data-query registry breaks the scrawn.js SDK's MetadataBuilder for any consumer currently using it. Both regressions would surface immediately on deploy. Several additional issues carried over from prior review rounds (global idempotency keys, session uniqueness, webhook URL migration, ClickHouse user scoping, and fetchLastBilled not scoping by project) also remain unresolved.

src/zod/internals.ts and the matching dashboard client need to be updated together; src/routes/gRPC/data/query.ts TABLE_REGISTRY removal needs a coordinated SDK deprecation; src/storage/db/postgres/schema.ts idempotency-key and session uniqueness constraints still need to be scoped per project.

Important Files Changed

Filename Overview
src/storage/db/postgres/schema.ts Major multi-tenant migration: adds projectsTable and projectId FK to all tables. Idempotency keys on basic/AI usage events are still globally unique, session uniqueness is still global, and tags/expressions have no unique constraint on active (project_id, key) — all flagged in prior rounds. The metadata relation is defined as many but the unique index allows only one row per project.
src/zod/internals.ts Adds required name field to onboardingSchema, but the dashboard's submitOnboarding never sends this field — every onboarding attempt will fail with a validation error until the dashboard is updated.
src/routes/gRPC/data/query.ts Removes metadata from TABLE_REGISTRY and scopes all queries to auth.projectId. Breaks scrawn.js SDK's MetadataBuilder which still requests the "metadata" table. Prior userId UUID cast issue (non-UUID user ids) is also unaddressed.
src/routes/http/api/onboarding.ts Rewrites onboarding to create a project, metadata, and dashboard API key in a transaction. Webhook rollback on DB failure is implemented. The duplicate-name check still runs outside the transaction (race condition) and no unique constraint guards the projects.name column.
src/routes/http/api/apiKeys.ts Creates API key and webhook endpoint atomically in a transaction, restricts all key management to dashboard role, scopes list/revoke to auth.projectId, and invalidates the cache on revoke. Solid improvements.
src/utils/authenticateMasterApiKey.ts New file implementing HMAC-based master key authentication. The stored hash format is not validated and Buffer.from(...) uses UTF-8 rather than hex decoding, so a malformed MASTER_API_KEY_HASH silently changes the comparison target — flagged in a prior round.
src/routes/gRPC/payment/paymentProvider.ts Switches from two global singletons to a per-project keyed Map, scopes all getMetadata calls to projectId. Clean implementation; clearClients and removeClient are both exported for different invalidation granularities.
src/storage/adapter/clickhouse/utils.ts Adds projectId to ClickHouse price query params. The fetchLastBilled helper still reads users by id alone, not (project_id, id), so cross-project user id collisions can return the wrong billing timestamp — flagged in prior round with artifact evidence.
src/storage/adapter/postgres/handlers/priceRequest.ts Adds projectId scoping to price calculation and fixes the join to match on both user_id and project_id. Removes a useless try/catch around parseInt and adds explicit radix.
src/routes/http/registerWebhookRoutes.ts Adds projectId query-param requirement and UUID validation. Existing Dodo webhook URLs registered before this change omit projectId and will now return 400 on every delivery until every provider URL is migrated — flagged in prior round.

Entity Relationship Diagram

%%{init: {'theme': 'neutral'}}%%
erDiagram
    projects {
        uuid id PK
        text name
        timestamptz created_at
    }
    api_keys {
        uuid id PK
        uuid project_id FK
        text name
        text key
        text role
        timestamptz expires_at
        bool revoked
    }
    users {
        text id PK
        uuid project_id PK
        timestamptz last_billed_timestamp
        text payment_provider_user_id
        text mode
    }
    sessions {
        uuid proxy_link_id PK
        uuid project_id FK
        text session_id
        text processed
        text user_id
        uuid api_key_id FK
        text mode
    }
    basic_usage_events {
        uuid id PK
        uuid project_id FK
        uuid event_id
        text idempotency_key
        text user_id
        uuid api_key_id FK
        bigint debit_amount
    }
    ai_token_usage_events {
        uuid id PK
        uuid project_id FK
        uuid event_id
        text idempotency_key
        text user_id
        uuid api_key_id FK
        text model
        jsonb metrics
    }
    payment_events {
        uuid id PK
        uuid project_id FK
        text user_id
        uuid api_key_id FK
        bigint credit_amount
        uuid proxy_id FK
    }
    metadata {
        uuid id PK
        uuid project_id FK
        text dodo_live_api_key
        text dodo_test_api_key
        text currency
        text redirect_url
    }
    tags {
        uuid id PK
        uuid project_id FK
        text key
        int amount
    }
    expressions {
        uuid id PK
        uuid project_id FK
        text key
        text expr
    }
    webhook_endpoints {
        uuid id PK
        uuid project_id FK
        uuid api_key_id FK
        text url
    }
    webhook_deliveries {
        uuid id PK
        uuid project_id FK
        uuid endpoint_id FK
        text event_type
        text status
    }

    projects ||--o{ api_keys : "owns"
    projects ||--o{ users : "owns"
    projects ||--o{ sessions : "owns"
    projects ||--|| metadata : "has"
    projects ||--o{ basic_usage_events : "has"
    projects ||--o{ ai_token_usage_events : "has"
    projects ||--o{ payment_events : "has"
    projects ||--o{ tags : "has"
    projects ||--o{ expressions : "has"
    projects ||--o{ webhook_endpoints : "has"
    projects ||--o{ webhook_deliveries : "has"
    api_keys ||--o{ sessions : "linked"
    api_keys ||--o{ webhook_endpoints : "linked"
    sessions ||--o{ payment_events : "via proxy_id"
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"}}}%%
erDiagram
    projects {
        uuid id PK
        text name
        timestamptz created_at
    }
    api_keys {
        uuid id PK
        uuid project_id FK
        text name
        text key
        text role
        timestamptz expires_at
        bool revoked
    }
    users {
        text id PK
        uuid project_id PK
        timestamptz last_billed_timestamp
        text payment_provider_user_id
        text mode
    }
    sessions {
        uuid proxy_link_id PK
        uuid project_id FK
        text session_id
        text processed
        text user_id
        uuid api_key_id FK
        text mode
    }
    basic_usage_events {
        uuid id PK
        uuid project_id FK
        uuid event_id
        text idempotency_key
        text user_id
        uuid api_key_id FK
        bigint debit_amount
    }
    ai_token_usage_events {
        uuid id PK
        uuid project_id FK
        uuid event_id
        text idempotency_key
        text user_id
        uuid api_key_id FK
        text model
        jsonb metrics
    }
    payment_events {
        uuid id PK
        uuid project_id FK
        text user_id
        uuid api_key_id FK
        bigint credit_amount
        uuid proxy_id FK
    }
    metadata {
        uuid id PK
        uuid project_id FK
        text dodo_live_api_key
        text dodo_test_api_key
        text currency
        text redirect_url
    }
    tags {
        uuid id PK
        uuid project_id FK
        text key
        int amount
    }
    expressions {
        uuid id PK
        uuid project_id FK
        text key
        text expr
    }
    webhook_endpoints {
        uuid id PK
        uuid project_id FK
        uuid api_key_id FK
        text url
    }
    webhook_deliveries {
        uuid id PK
        uuid project_id FK
        uuid endpoint_id FK
        text event_type
        text status
    }

    projects ||--o{ api_keys : "owns"
    projects ||--o{ users : "owns"
    projects ||--o{ sessions : "owns"
    projects ||--|| metadata : "has"
    projects ||--o{ basic_usage_events : "has"
    projects ||--o{ ai_token_usage_events : "has"
    projects ||--o{ payment_events : "has"
    projects ||--o{ tags : "has"
    projects ||--o{ expressions : "has"
    projects ||--o{ webhook_endpoints : "has"
    projects ||--o{ webhook_deliveries : "has"
    api_keys ||--o{ sessions : "linked"
    api_keys ||--o{ webhook_endpoints : "linked"
    sessions ||--o{ payment_events : "via proxy_id"
Loading

Comments Outside Diff (1)

  1. src/routes/gRPC/data/query.ts, line 87-97 (link)

    P1 MetadataBuilder in scrawn.js SDK will receive an unknown-table error

    The metadata entry was removed from TABLE_REGISTRY, but scrawn.js's MetadataBuilder still targets "metadata" in callDataQuery. Any SDK consumer calling analytics.query.metadata will get an unrecognised-table error from the server. Removing sensitive config from the queryable surface is the right call, but the SDK needs a coordinated deprecation before the server-side removal ships.

Reviews (2): Last reviewed commit: "fix: prevent dashboard self-revoke, remo..." | Re-trigger Greptile

Comment thread src/storage/adapter/postgres/handlers/addBasicUsage.ts
Comment on lines +169 to +173
projectId: uuid("project_id")
.references(() => projectsTable.id)
.notNull(),
eventId: uuid("event_id").notNull(),
idempotencyKey: text("idempotency_key").notNull().unique(),

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 Scope idempotency by project

idempotencyKey stays globally unique even though basic usage events now belong to projects. If two projects send the same idempotency key, the second project's valid event will hit a duplicate-key error and fail ingestion. The unique constraint should include projectId so idempotency is isolated per project.

Comment on lines +280 to +284
projectId: uuid("project_id")
.references(() => projectsTable.id)
.notNull(),
eventId: uuid("event_id").notNull(),
idempotencyKey: text("idempotency_key").notNull().unique(),

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 Scope AI idempotency

AI token usage idempotency is still enforced globally while the rows now carry projectId. A normal retry key reused by another project can block that project's event with a unique-constraint failure. This should be a composite unique key on projectId and idempotencyKey.

Comment thread src/utils/parseExpr.ts
Comment on lines 37 to +40
"div",
"tag",
"expr",
"inputTokens",
"outputTokens",
"inputCacheTokens",
"outputCacheTokens",
"inputtokens",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Token casing now misvalidates

Validation lowercases function names before checking ALLOWED_FUNCTIONS, and this change adds lowercase token placeholder names. That means inputtokens() now passes validation, but resolveTokenPlaceholders only replaces exact camelCase forms like inputTokens(), so evaluation later sees an unknown function and the event fails. Either reject the lowercase spelling or normalize replacements case-insensitively.

Artifacts

Repro: focused Bun harness for lowercase token placeholder validation and evaluation

  • Contains supporting evidence from the run (text/typescript; charset=utf-8).

Repro: Bun harness output showing validation success, unresolved replacement, and evaluation error

  • Keeps the command output available without making the summary code-heavy.

Repro: focused Vitest test for lowercase token placeholder behavior

  • Contains supporting evidence from the run (text/typescript; charset=utf-8).

Repro: Vitest startup output captured during attempted focused test run

  • Keeps the command output available without making the summary code-heavy.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment on lines 54 to +72
return {};
}

const projectId = randomUUID();

const existing = await getPostgresDB()
.select({ id: projectsTable.id })
.from(projectsTable)
.where(eq(projectsTable.name, validated.name))
.limit(1);

if (existing.length > 0) {
builder.setError(409, {
type: "ConflictError",
message: `Project with name '${validated.name}' already exists`,
});
reply.code(409);
return {};
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Make project names unique atomically

The duplicate-name check runs outside the transaction, and the schema does not add a unique constraint on projects.name. Two concurrent onboarding requests with the same name can both pass this check and create duplicate projects with separate dashboard keys. Add a unique index on project name and handle the insert conflict in the transaction.

Comment thread src/routes/http/api/expressions.ts
Comment thread src/routes/http/api/expressions.ts
Comment on lines 50 to +66
return { error: "Invalid mode query parameter" };
}

if (!projectId) {
builder.setError(400, {
type: "ValidationError",
message: "Missing 'projectId' query parameter.",
});
reply.code(400);
return { error: "Missing projectId query parameter" };
}

if (
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
projectId
)
) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Handle existing webhooks

The payment webhook now rejects every request without a projectId query parameter before processing it. Existing Dodo webhooks created before this change use only mode, so after upgrading they will return 400 and leave successful payments unprocessed until every provider webhook URL is migrated.

T-Rex Ran code and verified through T-Rex

Comment on lines +353 to +364
.notNull(),
last_run_at: timestamp("last_run_at", {
withTimezone: true,
mode: "string",
}),
dodo_live_api_key: text("dodo_live_api_key").notNull(),
dodo_test_api_key: text("dodo_test_api_key").notNull(),
dodo_live_product_id: text("dodo_live_product_id").notNull(),
dodo_test_product_id: text("dodo_test_product_id").notNull(),
dodo_live_webhook_secret: text("dodo_live_webhook_secret").notNull(),
dodo_test_webhook_secret: text("dodo_test_webhook_secret").notNull(),
currency: text("currency").notNull().default("usd"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Enforce unique active tags

tags now include projectId, but the table still has no uniqueness constraint for active (project_id, key) rows. Concurrent creates for the same tag can insert duplicates, and later lookups using limit(1) can return an arbitrary amount.

Comment on lines +34 to +37
if (
incomingHash.length !== storedHash.length ||
!timingSafeEqual(Buffer.from(incomingHash), Buffer.from(storedHash))
) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Validate stored hash format

The comparison checks string length but does not validate that MASTER_API_KEY_HASH is a 64-character hex digest, and Buffer.from(...) uses UTF-8 rather than hex decoding. A malformed stored hash silently changes the comparison target instead of failing configuration validation.

T-Rex Ran code and verified through T-Rex

Comment thread src/zod/internals.ts
Comment on lines 34 to +36

export const onboardingSchema = z.object({
name: z.string().min(1, "Project name is required").max(255),

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 Dashboard onboarding will fail — name field not sent

The onboardingSchema now requires a name field, but scrawn.js/dashboard's submitOnboarding neither collects nor forwards it — the request body is built from { dodoLiveApiKey, dodoTestApiKey, ... } with no name. Every onboarding submission will return a Zod validation error and the onboarding UI will be broken until the dashboard is updated to collect and send this field.

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.

1 participant