stack/apps/backend/prisma/seed.ts
BilalG1 38ae913fc9
Some checks failed
all-good: Did all the other checks pass? / all-good (push) Has been cancelled
Ensure Prisma migrations are in sync with the schema / check_prisma_migrations (22.x) (push) Has been cancelled
DB migration compat / Check if migrations changed (push) Has been cancelled
Docker Server Build and Push / Docker Build and Push Server (push) Has been cancelled
Docker Server Build and Run / docker (push) Has been cancelled
Runs E2E API Tests (Local Emulator) / E2E Tests (Local Emulator, Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (mock, 22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (prod, 22.x) (push) Has been cancelled
Runs E2E API Tests with custom port prefix / build (22.x) (push) Has been cancelled
Runs E2E Fallback Tests / E2E Fallback Tests (Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Lint & build / lint_and_build (24) (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
DB migration compat / Back-compat — Current branch migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migration compat / Forward-compat — Current branch code with ${{ needs.check-migrations-changed.outputs.base_branch }} branch migrations (push) Has been cancelled
DB migration compat / No migration changes (skipped) (push) Has been cancelled
Rename STACK_* env vars to HEXCLAVE_* in env templates, with legacy dual-read (#1588)
## Summary

Completes the env-var side of the Hexclave rebrand: every
`STACK_*`-prefixed variable (including `NEXT_PUBLIC_STACK_*` and
`VITE_STACK_*`) is renamed to `HEXCLAVE_*` across all checked-in `.env`,
`.env.development`, and `.env.example` files (30 files, ~135 keys).
Legacy `STACK_*` names keep working everywhere via dual-read, so
**existing deployments, `.env.local` files, and self-hosted setups need
no immediate migration**.

## How legacy names keep working

- **Server code** already resolves `HEXCLAVE_*` first with `STACK_*`
fallback via `getEnvVariable`. Direct `process.env.STACK_X` readers fed
by the renamed files (prisma seed, e2e tests/helpers, internal-tool
scripts, examples, `prisma.config.ts`) now read `HEXCLAVE_X || STACK_X`.
- **Client code** (Next.js build-time inlining) uses literal dual-read
expressions; the dashboard's `_inlineEnvVars` already had them.
- **Docker/self-hosting**: `docker/server/entrypoint.sh` (shared by the
server and local-emulator images) gets a generic two-way
`HEXCLAVE_`↔`STACK_` env mirror — runs at startup and again before
sentinel replacement — replacing the previous URL-trio-only mirror.
Operators can use either prefix.

## The empty-placeholder trap (`||` vs `??`)

The checked-in templates define empty placeholders (`HEXCLAVE_X=#
comment` parses to `""` via dotenv). With `?? `-based fallbacks, that
empty string would silently shadow a real value under the legacy name —
including legacy vars set in Vercel/CI env at build time, since the
tracked `.env` is present during builds. All fallback chains therefore
treat empty-as-unset (`||`):

- `getEnvVariable` and `getProcessEnv` in `packages/shared`
- the dashboard/docs/example literal dual-reads
- the generated SDK env getters (via
`packages/template/scripts/generate-env.ts`; the generated
`src/generated/env.ts` files are gitignored and regenerate at build)

## Other notable changes

- Tests that override env now set the canonical `HEXCLAVE_*` name (it
wins over `STACK_*`): e2e `cross-domain-auth`, backend
`internal-feedback-emails` in-source test.
- e2e `helpers.ts` port-prefix expansion loop also matches the
`HEXCLAVE_` prefixes.
- `docker/local-emulator/generate-env-development.mjs` reads source keys
canonically (legacy fallback) and emits canonical keys; regenerated
output matches.
- `rotate-secrets.sh` falls back to
`HEXCLAVE_DATABASE_CONNECTION_STRING`.
- Docs code snippets (`docs/code-examples`) renamed outright to
canonical names, consistent with #1571.
- OAuth callback `console.warn` in `packages/template/src/lib/auth.ts`
now says Hexclave.

## Migration note for the team

Local `.env.local` files with legacy `STACK_*` overrides keep working
**unless** the override targets a var that `.env.development` now sets
to a real (non-empty) `HEXCLAVE_*` value — the canonical name wins over
file precedence. Rename those keys in your `.env.local` once.

## Verification

- `typecheck` + `lint` pass on every touched package (shared, backend,
dashboard, e2e, internal-tool, cli, docs, template). Pre-existing
failures on dev (`admin-app-impl.ts` typecheck, dashboard metrics-page
errors) are unchanged (identical error counts with/without this change).
- `getEnvVariable`/`getProcessEnv` fallback semantics smoke-tested
directly (empty-HEXCLAVE → legacy fallback, HEXCLAVE wins when set,
defaults intact).
- `internal-feedback-emails` in-source vitest passes; emulator env
generator `--check` passes; `bash -n` on touched shell scripts.
- Two independent review agents audited the diff for correctness bugs
and coverage gaps; all confirmed findings are fixed in the third commit.

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Renamed all `STACK_*` env vars (including
`NEXT_PUBLIC_STACK_*`/`VITE_STACK_*`) to `HEXCLAVE_*` across env
templates and code, with dual‑read that treats empty as unset, detects
conflicts, ignores post‑build sentinels, and falls back to legacy names.
All GitHub Actions now use `HEXCLAVE_*`; local‑emulator e2e is fixed by
setting `NEXT_PUBLIC_HEXCLAVE_IS_LOCAL_EMULATOR` in CI.

- **Refactors**
- Added conflict‑aware dual‑read helpers (prefer `HEXCLAVE_*`,
empty‑as‑unset, ignore post‑build sentinels, preserve empty passthrough)
and used them across `packages/shared` (resolver + tests),
`apps/dashboard` inline/public envs (with tests), `apps/backend` Prisma
config/seed and vitest (accept both prefixes), `packages/cli`
(API/Dashboard URLs, project ID, `HEXCLAVE_EMULATOR_HOME`; tests),
Docker (`entrypoint.sh` mirroring + `rotate-secrets.sh` DB URL),
docs/components (`docs/src/lib/env.ts`), and examples; hosted/Vite apps
now error if both spellings differ.
- Port‑prefix expansion includes `HEXCLAVE_*`; backend tests use a new
helper to resolve DB connection strings; Prisma prefers
`HEXCLAVE_DATABASE_CONNECTION_STRING` with legacy fallback.
- Generated SDK env getters use plain `HEXCLAVE_*` || `STACK_*` (no
conflict throw); dashboard inline resolver preserves empty/sentinel
passthrough to avoid build failures; docs/examples include dual‑read
utilities.
- Tests now stub canonical `HEXCLAVE_*` flags (e.g., plan limits, bot
challenge, OAuth tokens, hosted handler) to avoid shadowing/conflict
with committed defaults.

- **Migration**
  - No immediate action; legacy `STACK_*` names still work.
- If both names are set with different values, builds/scripts error. Set
only `HEXCLAVE_*` or make both equal.
- SDK consumers won’t see conflict throws; update env names to
`HEXCLAVE_*` over time.

<sup>Written for commit 7539fb9fbf.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/hexclave/hexclave/pull/1588?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Chores**
* Migrated environment variable names from the legacy `STACK_*` prefix
to the new `HEXCLAVE_*` prefix across backend, dashboard, tooling,
Docker, and examples.
* Updated environment/config resolution to prefer `HEXCLAVE_*`, treat
empty strings as unset, and detect conflicts when both `STACK_*` and
`HEXCLAVE_*` are set to different values.
* Updated local emulator, server startup, and env-generation workflows
to use the new names (with legacy fallback where applicable).
* **Documentation**
  * Updated docs and code examples to reference `HEXCLAVE_*` variables.
* **Tests**
* Refreshed unit and e2e coverage to validate dual-read behavior,
conflict detection, and empty-value handling.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-19 18:58:53 -07:00

675 lines
26 KiB
TypeScript

/* eslint-disable no-restricted-syntax */
import { usersCrudHandlers } from '@/app/api/latest/users/crud';
import { CustomerType, Prisma, PurchaseCreationSource, SubscriptionStatus } from '@/generated/prisma/client';
import { overrideBranchConfigOverride } from '@/lib/config';
import {
LOCAL_EMULATOR_ADMIN_EMAIL,
LOCAL_EMULATOR_ADMIN_PASSWORD,
LOCAL_EMULATOR_ADMIN_USER_ID,
LOCAL_EMULATOR_OWNER_TEAM_ID,
isLocalEmulatorEnabled,
} from '@/lib/local-emulator';
import { ensurePermissionDefinition, grantTeamPermission } from '@/lib/permissions';
import { createOrUpdateProjectWithLegacyConfig, getProject } from '@/lib/projects';
import { seedDummyProject } from '@/lib/seed-dummy-data';
import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from '@/lib/tenancies';
import { getPrismaClientForTenancy, globalPrismaClient } from '@/prisma-client';
import { ALL_APPS } from '@hexclave/shared/dist/apps/apps-config';
import { DEFAULT_EMAIL_THEME_ID } from '@hexclave/shared/dist/helpers/emails';
import { AdminUserProjectsCrud } from '@hexclave/shared/dist/interface/crud/projects';
import { ITEM_IDS, PLAN_LIMITS } from '@hexclave/shared/dist/plans';
import { DayInterval } from '@hexclave/shared/dist/utils/dates';
import { getEnvVariable } from '@hexclave/shared/dist/utils/env';
import { throwErr } from '@hexclave/shared/dist/utils/errors';
import { typedEntries, typedFromEntries } from '@hexclave/shared/dist/utils/objects';
const MONTHLY_REPEAT: DayInterval = [1, "month"];
const DUMMY_PROJECT_ID = '6fbbf22e-f4b2-4c6e-95a1-beab6fa41063';
const DEVELOPMENT_ENVIRONMENT_PROJECT_ID = '5f2a45c8-9096-4f0b-b987-7640a47f7a79';
let didEnableSeedLogTimestamps = false;
function enableSeedLogTimestamps() {
if (didEnableSeedLogTimestamps) return;
didEnableSeedLogTimestamps = true;
const originalLog = console.log.bind(console);
const originalInfo = console.info.bind(console);
const originalWarn = console.warn.bind(console);
const originalError = console.error.bind(console);
const withTimestamp = (...data: unknown[]) => [`[${new Date().toISOString()}]`, ...data];
console.log = (...data: Parameters<typeof console.log>) => {
originalLog(...withTimestamp(...data));
};
console.info = (...data: Parameters<typeof console.info>) => {
originalInfo(...withTimestamp(...data));
};
console.warn = (...data: Parameters<typeof console.warn>) => {
originalWarn(...withTimestamp(...data));
};
console.error = (...data: Parameters<typeof console.error>) => {
originalError(...withTimestamp(...data));
};
}
export async function seed() {
enableSeedLogTimestamps();
process.env.HEXCLAVE_SEED_MODE = 'true';
console.log('Seeding database...');
// Optional default admin user
const adminEmail = getEnvVariable("STACK_SEED_INTERNAL_PROJECT_USER_EMAIL", "");
const adminPassword = getEnvVariable("STACK_SEED_INTERNAL_PROJECT_USER_PASSWORD", "");
const adminInternalAccess = getEnvVariable("STACK_SEED_INTERNAL_PROJECT_USER_INTERNAL_ACCESS", "") === 'true';
const adminGithubId = getEnvVariable("STACK_SEED_INTERNAL_PROJECT_USER_GITHUB_ID", "");
// dashboard settings
const dashboardDomain = getEnvVariable("NEXT_PUBLIC_STACK_DASHBOARD_URL", "");
const rawOauthProviderIds = getEnvVariable("STACK_SEED_INTERNAL_PROJECT_OAUTH_PROVIDERS", "");
const oauthProviderIds = rawOauthProviderIds ? rawOauthProviderIds.split(',') : [];
const otpEnabled = getEnvVariable("STACK_SEED_INTERNAL_PROJECT_OTP_ENABLED", "") === 'true';
const signUpEnabled = getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SIGN_UP_ENABLED", "") === 'true';
const allowLocalhost = getEnvVariable("STACK_SEED_INTERNAL_PROJECT_ALLOW_LOCALHOST", "") === 'true';
const localEmulatorEnabled = isLocalEmulatorEnabled();
const apiKeyId = '3142e763-b230-44b5-8636-aa62f7489c26';
const defaultUserId = '33e7c043-d2d1-4187-acd3-f91b5ed64b46';
const internalTeamId = 'a23e1b7f-ab18-41fc-9ee6-7a9ca9fa543c';
let internalProject = await getProject('internal');
if (!internalProject) {
internalProject = await createOrUpdateProjectWithLegacyConfig({
type: 'create',
projectId: 'internal',
data: {
display_name: 'Hexclave Dashboard',
owner_team_id: internalTeamId,
description: 'Hexclave\'s admin dashboard',
is_production_mode: false,
config: {
allow_localhost: true,
oauth_providers: oauthProviderIds.map((id) => ({
id: id as any,
type: 'shared',
})),
sign_up_enabled: signUpEnabled,
credential_enabled: true,
magic_link_enabled: otpEnabled,
},
},
});
console.log('Internal project created');
}
const internalTenancy = await getSoleTenancyFromProjectBranch("internal", DEFAULT_BRANCH_ID);
const internalPrisma = await getPrismaClientForTenancy(internalTenancy);
internalProject = await createOrUpdateProjectWithLegacyConfig({
projectId: 'internal',
branchId: DEFAULT_BRANCH_ID,
type: 'update',
data: {
config: {
create_team_on_sign_up: true,
sign_up_enabled: signUpEnabled,
magic_link_enabled: otpEnabled,
allow_localhost: allowLocalhost,
client_team_creation_enabled: true,
domains: [
...(dashboardDomain && new URL(dashboardDomain).hostname !== 'localhost' ? [{ domain: dashboardDomain, handler_path: '/handler' }] : []),
...Object.values(internalTenancy.config.domains.trustedDomains)
.filter((d) => d.baseUrl !== dashboardDomain && d.baseUrl)
.map((d) => ({ domain: d.baseUrl || throwErr('Domain base URL is required'), handler_path: d.handlerPath })),
],
},
},
});
await overrideBranchConfigOverride({
projectId: 'internal',
branchId: DEFAULT_BRANCH_ID,
branchConfigOverrideOverride: {
// Disable email verification for internal project - dashboard admins shouldn't need to verify their email
onboarding: {
requireEmailVerification: false,
},
dataVault: {
stores: {
'neon-connection-strings': {
displayName: 'Neon Connection Strings',
}
}
},
payments: {
productLines: {
plans: {
displayName: "Plans",
customerType: "team",
},
},
products: {
free: {
productLineId: "plans",
displayName: "Free",
customerType: "team",
serverOnly: false,
stackable: false,
prices: {
"free-monthly": {
USD: "0",
interval: [1, "month"] as any,
},
},
includedItems: {
[ITEM_IDS.seats]: { quantity: PLAN_LIMITS.free.seats, repeat: "never" as const, expires: "when-purchase-expires" as const },
[ITEM_IDS.authUsers]: { quantity: PLAN_LIMITS.free.authUsers, repeat: "never" as const, expires: "when-purchase-expires" as const },
[ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.free.emailsPerMonth, repeat: MONTHLY_REPEAT, expires: "when-repeated" as const },
[ITEM_IDS.analyticsTimeoutSeconds]: { quantity: PLAN_LIMITS.free.analyticsTimeoutSeconds, repeat: "never" as const, expires: "when-purchase-expires" as const },
[ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.free.analyticsEvents, repeat: MONTHLY_REPEAT, expires: "when-repeated" as const },
[ITEM_IDS.sessionReplays]: { quantity: PLAN_LIMITS.free.sessionReplays, repeat: MONTHLY_REPEAT, expires: "when-repeated" as const },
},
},
team: {
productLineId: "plans",
displayName: "Team",
customerType: "team",
serverOnly: false,
stackable: false,
prices: {
monthly: {
USD: "49",
interval: MONTHLY_REPEAT,
serverOnly: false,
},
},
includedItems: {
[ITEM_IDS.seats]: { quantity: PLAN_LIMITS.team.seats, repeat: "never" as const, expires: "when-purchase-expires" as const },
[ITEM_IDS.authUsers]: { quantity: PLAN_LIMITS.team.authUsers, repeat: "never" as const, expires: "when-purchase-expires" as const },
[ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.team.emailsPerMonth, repeat: MONTHLY_REPEAT, expires: "when-repeated" as const },
[ITEM_IDS.analyticsTimeoutSeconds]: { quantity: PLAN_LIMITS.team.analyticsTimeoutSeconds, repeat: "never" as const, expires: "when-purchase-expires" as const },
[ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.team.analyticsEvents, repeat: MONTHLY_REPEAT, expires: "when-repeated" as const },
[ITEM_IDS.sessionReplays]: { quantity: PLAN_LIMITS.team.sessionReplays, repeat: MONTHLY_REPEAT, expires: "when-repeated" as const },
[ITEM_IDS.onboardingCall]: { quantity: 1, repeat: "never" as const, expires: "when-purchase-expires" as const },
},
},
growth: {
productLineId: "plans",
displayName: "Growth",
customerType: "team",
serverOnly: false,
stackable: false,
prices: {
monthly: {
USD: "299",
interval: MONTHLY_REPEAT,
serverOnly: false,
},
},
includedItems: {
[ITEM_IDS.seats]: { quantity: PLAN_LIMITS.growth.seats, repeat: "never" as const, expires: "when-purchase-expires" as const },
[ITEM_IDS.authUsers]: { quantity: PLAN_LIMITS.growth.authUsers, repeat: "never" as const, expires: "when-purchase-expires" as const },
[ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.growth.emailsPerMonth, repeat: MONTHLY_REPEAT, expires: "when-repeated" as const },
[ITEM_IDS.analyticsTimeoutSeconds]: { quantity: PLAN_LIMITS.growth.analyticsTimeoutSeconds, repeat: "never" as const, expires: "when-purchase-expires" as const },
[ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.growth.analyticsEvents, repeat: MONTHLY_REPEAT, expires: "when-repeated" as const },
[ITEM_IDS.sessionReplays]: { quantity: PLAN_LIMITS.growth.sessionReplays, repeat: MONTHLY_REPEAT, expires: "when-repeated" as const },
[ITEM_IDS.onboardingCall]: { quantity: 1, repeat: "never" as const, expires: "when-purchase-expires" as const },
},
},
"extra-seats": {
productLineId: "plans",
displayName: "Extra Seats",
customerType: "team",
serverOnly: false,
stackable: true,
prices: {
monthly: {
USD: "29",
interval: MONTHLY_REPEAT,
serverOnly: false,
},
},
includedItems: {
[ITEM_IDS.seats]: { quantity: 1, repeat: "never" as const, expires: "when-purchase-expires" as const },
},
isAddOnTo: {
team: true,
growth: true,
},
},
},
items: {
[ITEM_IDS.seats]: { displayName: "Dashboard Admins", customerType: "team" as const },
[ITEM_IDS.authUsers]: { displayName: "Auth Users", customerType: "team" as const },
[ITEM_IDS.emailsPerMonth]: { displayName: "Emails per Month", customerType: "team" as const },
[ITEM_IDS.analyticsTimeoutSeconds]: { displayName: "Analytics Timeout (seconds)", customerType: "team" as const },
[ITEM_IDS.analyticsEvents]: { displayName: "Analytics Events", customerType: "team" as const },
[ITEM_IDS.sessionReplays]: { displayName: "Session Replays", customerType: "team" as const },
[ITEM_IDS.onboardingCall]: { displayName: "Onboarding Call", customerType: "team" as const },
},
},
apps: {
installed: typedFromEntries(typedEntries(ALL_APPS).map(([key, value]) => [key, { enabled: true }])),
},
}
});
await ensurePermissionDefinition(
globalPrismaClient,
internalPrisma,
{
id: "team_member",
scope: "team",
tenancy: internalTenancy,
data: {
description: "1",
contained_permission_ids: ["$read_members"],
}
}
);
const updatedInternalTenancy = await getSoleTenancyFromProjectBranch("internal", DEFAULT_BRANCH_ID);
await ensurePermissionDefinition(
globalPrismaClient,
internalPrisma,
{
id: "team_admin",
scope: "team",
tenancy: updatedInternalTenancy,
data: {
description: "2",
contained_permission_ids: ["$read_members", "$remove_members", "$update_team"],
}
}
);
const internalTeam = await internalPrisma.team.findUnique({
where: {
tenancyId_teamId: {
tenancyId: internalTenancy.id,
teamId: internalTeamId,
},
},
});
if (!internalTeam) {
await internalPrisma.team.create({
data: {
tenancyId: internalTenancy.id,
teamId: internalTeamId,
displayName: 'Internal Team',
mirroredProjectId: 'internal',
mirroredBranchId: DEFAULT_BRANCH_ID,
},
});
console.log('Internal team created');
}
// The team-create CRUD path auto-grants the free plan to every team in the
// internal project, but the internal team itself is written directly above
// (bypassing that code path), so it would otherwise end up with zero
// entitlements and trip the plan-limit enforcement. Grant it the Growth plan
// so Hexclave employees using the dashboard get full quotas. Idempotent —
// skipped if an active Growth subscription already exists.
//
// We create the subscription with raw Prisma (matching seed-dummy-data.ts)
// rather than grantProductToCustomer because bulldozer storage tables
// aren't initialized at this point in the seed yet. The Bulldozer init
// call right below this block ingresses the row into the ledger.
const growthProduct = updatedInternalTenancy.config.payments.products.growth;
if (growthProduct.customerType === 'team') {
const existingGrowthSub = await internalPrisma.subscription.findFirst({
where: {
tenancyId: internalTenancy.id,
customerId: internalTeamId,
customerType: CustomerType.TEAM,
productId: 'growth',
status: SubscriptionStatus.active,
},
});
if (!existingGrowthSub) {
const firstPriceId = Object.keys(growthProduct.prices)[0];
if (!firstPriceId) {
throw new Error("Internal seed invariant violated: the Growth product must have at least one price configured before seeding the internal team subscription.");
}
const now = new Date();
// Clone to ensure the stored JSON snapshot is independent of the config object
// (mirrors the pattern used in seed-dummy-data.ts).
const storedProduct = JSON.parse(JSON.stringify(growthProduct)) as Prisma.InputJsonValue;
// Mirror what a real Stripe checkout would produce, based on whether
// the internal project is running in test mode.
const creationSource = updatedInternalTenancy.config.payments.testMode
? PurchaseCreationSource.TEST_MODE
: PurchaseCreationSource.PURCHASE_PAGE;
await internalPrisma.subscription.create({
data: {
tenancyId: internalTenancy.id,
customerId: internalTeamId,
customerType: CustomerType.TEAM,
status: SubscriptionStatus.active,
productId: 'growth',
priceId: firstPriceId,
product: storedProduct,
quantity: 1,
currentPeriodStart: now,
currentPeriodEnd: new Date('2099-12-31T23:59:59Z'),
cancelAtPeriodEnd: false,
creationSource,
},
});
console.log('Granted Growth plan to internal team');
}
}
// Upsert the internal API key set before any flake-prone work (dummy-project
// seed, email/svix, clickhouse). The emulator CLI authenticates against the
// internal project using the pck stored here, so it must land before the rest
// of the seed even if something later fails.
const isLocalEmulator = getEnvVariable("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR", "") === 'true';
const rawPck = getEnvVariable("STACK_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY", "");
if (isLocalEmulator && !rawPck) {
// Emulator images build before a per-VM pck is available. Runtime boots set
// HEXCLAVE_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY from the VM-generated
// random value and re-run the seed, which upserts the internal key set then.
console.log('Skipping internal API key set (no pck provided; emulator mode).');
} else {
const keySet = {
publishableClientKey: rawPck || throwErr('HEXCLAVE_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY is not set'),
secretServerKey: isLocalEmulator
? (getEnvVariable("STACK_INTERNAL_PROJECT_SECRET_SERVER_KEY", "") || null)
: (getEnvVariable("STACK_INTERNAL_PROJECT_SECRET_SERVER_KEY", "") || throwErr('HEXCLAVE_INTERNAL_PROJECT_SECRET_SERVER_KEY is not set')),
superSecretAdminKey: isLocalEmulator
? (getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY", "") || null)
: (getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY", "") || throwErr('HEXCLAVE_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY is not set')),
};
await globalPrismaClient.apiKeySet.upsert({
where: { projectId_id: { projectId: 'internal', id: apiKeyId } },
update: {
...keySet,
},
create: {
id: apiKeyId,
projectId: 'internal',
description: "Internal API key set",
expiresAt: new Date('2099-12-31T23:59:59Z'),
...keySet,
}
});
console.log('Updated internal API key set');
}
const shouldSeedDummyProject = getEnvVariable("STACK_SEED_ENABLE_DUMMY_PROJECT", "") === 'true';
if (shouldSeedDummyProject) {
await seedDummyProject({
projectId: DUMMY_PROJECT_ID,
ownerTeamId: internalTeamId,
oauthProviderIds,
});
}
const developmentEnvironmentProjectData = {
display_name: 'Development Environment Project',
description: 'Seeded project for debugging development-environment dashboard behavior.',
is_production_mode: false,
is_development_environment: true,
owner_team_id: internalTeamId,
config: {
allow_localhost: true,
sign_up_enabled: true,
credential_enabled: true,
magic_link_enabled: true,
passkey_enabled: true,
client_team_creation_enabled: true,
client_user_deletion_enabled: true,
allow_user_api_keys: true,
allow_team_api_keys: true,
create_team_on_sign_up: false,
email_theme: DEFAULT_EMAIL_THEME_ID,
email_config: {
type: 'shared',
},
oauth_providers: oauthProviderIds.map((id) => ({
id: id as any,
type: 'shared',
})),
domains: [],
},
} satisfies AdminUserProjectsCrud["Admin"]["Create"];
if (await getProject(DEVELOPMENT_ENVIRONMENT_PROJECT_ID)) {
await createOrUpdateProjectWithLegacyConfig({
type: 'update',
projectId: DEVELOPMENT_ENVIRONMENT_PROJECT_ID,
branchId: DEFAULT_BRANCH_ID,
data: developmentEnvironmentProjectData,
});
} else {
await createOrUpdateProjectWithLegacyConfig({
type: 'create',
projectId: DEVELOPMENT_ENVIRONMENT_PROJECT_ID,
data: developmentEnvironmentProjectData,
});
}
// Create optional default admin user if credentials are provided.
// This user will be able to login to the dashboard with both email/password and magic link.
if ((adminEmail && adminPassword) || adminGithubId) {
const oldAdminUser = await internalPrisma.projectUser.findFirst({
where: {
mirroredProjectId: 'internal',
mirroredBranchId: DEFAULT_BRANCH_ID,
projectUserId: defaultUserId
}
});
if (oldAdminUser) {
console.log(`Admin user already exists, skipping creation`);
} else {
const newUser = await internalPrisma.projectUser.create({
data: {
displayName: 'Administrator (created by seed script)',
projectUserId: defaultUserId,
tenancyId: internalTenancy.id,
mirroredProjectId: 'internal',
mirroredBranchId: DEFAULT_BRANCH_ID,
signedUpAt: new Date(),
signUpRiskScoreBot: 0,
signUpRiskScoreFreeTrialAbuse: 0,
}
});
// Note: TeamMember creation is handled by the upsert below (after this if/else block)
// to ensure idempotency when adminInternalAccess changes between runs
if (adminEmail && adminPassword) {
await usersCrudHandlers.adminUpdate({
tenancy: internalTenancy,
user_id: defaultUserId,
data: {
password: adminPassword,
primary_email: adminEmail,
primary_email_auth_enabled: true,
},
});
console.log(`Added admin user with email ${adminEmail}`);
}
if (adminGithubId) {
const githubAccount = await internalPrisma.projectUserOAuthAccount.findFirst({
where: {
tenancyId: internalTenancy.id,
configOAuthProviderId: 'github',
providerAccountId: adminGithubId,
}
});
if (githubAccount) {
console.log(`GitHub account already exists, skipping creation`);
} else {
await internalPrisma.projectUserOAuthAccount.create({
data: {
tenancyId: internalTenancy.id,
projectUserId: newUser.projectUserId,
configOAuthProviderId: 'github',
providerAccountId: adminGithubId
}
});
await internalPrisma.authMethod.create({
data: {
tenancyId: internalTenancy.id,
projectUserId: newUser.projectUserId,
oauthAuthMethod: {
create: {
projectUserId: newUser.projectUserId,
configOAuthProviderId: 'github',
providerAccountId: adminGithubId,
}
}
}
});
console.log(`Added admin user with GitHub ID ${adminGithubId}`);
}
}
}
// Create or ensure TeamMember exists before granting permissions.
// Using upsert here (instead of create inside the else block above) ensures
// idempotency when adminInternalAccess changes between seed runs.
if (adminInternalAccess) {
await internalPrisma.teamMember.upsert({
where: {
tenancyId_projectUserId_teamId: {
tenancyId: internalTenancy.id,
projectUserId: defaultUserId,
teamId: internalTeamId,
},
},
create: {
tenancyId: internalTenancy.id,
teamId: internalTeamId,
projectUserId: defaultUserId,
},
update: {},
});
await grantTeamPermission(internalPrisma, {
tenancy: internalTenancy,
teamId: internalTeamId,
userId: defaultUserId,
permissionId: "team_admin",
});
}
}
if (localEmulatorEnabled) {
const emulatorTeam = await internalPrisma.team.findUnique({
where: {
tenancyId_teamId: {
tenancyId: internalTenancy.id,
teamId: LOCAL_EMULATOR_OWNER_TEAM_ID,
},
},
});
if (!emulatorTeam) {
await internalPrisma.team.create({
data: {
tenancyId: internalTenancy.id,
teamId: LOCAL_EMULATOR_OWNER_TEAM_ID,
displayName: 'Emulator Team',
mirroredProjectId: "internal",
mirroredBranchId: DEFAULT_BRANCH_ID,
},
});
console.log('Created emulator team');
}
const existingUser = await internalPrisma.projectUser.findFirst({
where: {
mirroredProjectId: 'internal',
mirroredBranchId: DEFAULT_BRANCH_ID,
projectUserId: LOCAL_EMULATOR_ADMIN_USER_ID,
}
});
if (existingUser) {
console.log('Emulator user already exists, skipping creation');
} else {
await internalPrisma.projectUser.create({
data: {
displayName: 'Local Emulator User',
projectUserId: LOCAL_EMULATOR_ADMIN_USER_ID,
tenancyId: internalTenancy.id,
mirroredProjectId: 'internal',
mirroredBranchId: DEFAULT_BRANCH_ID,
signedUpAt: new Date(),
signUpRiskScoreBot: 0,
signUpRiskScoreFreeTrialAbuse: 0,
}
});
console.log('Created emulator user');
}
await internalPrisma.teamMember.upsert({
where: {
tenancyId_projectUserId_teamId: {
tenancyId: internalTenancy.id,
projectUserId: LOCAL_EMULATOR_ADMIN_USER_ID,
teamId: LOCAL_EMULATOR_OWNER_TEAM_ID,
},
},
create: {
tenancyId: internalTenancy.id,
teamId: LOCAL_EMULATOR_OWNER_TEAM_ID,
projectUserId: LOCAL_EMULATOR_ADMIN_USER_ID,
},
update: {},
});
await usersCrudHandlers.adminUpdate({
tenancy: internalTenancy,
user_id: LOCAL_EMULATOR_ADMIN_USER_ID,
data: {
password: LOCAL_EMULATOR_ADMIN_PASSWORD,
primary_email: LOCAL_EMULATOR_ADMIN_EMAIL,
primary_email_auth_enabled: true,
},
});
const userTeamMembership = await internalPrisma.teamMember.findUnique({
where: {
tenancyId_projectUserId_teamId: {
tenancyId: internalTenancy.id,
projectUserId: LOCAL_EMULATOR_ADMIN_USER_ID,
teamId: LOCAL_EMULATOR_OWNER_TEAM_ID,
},
},
select: {
projectUserId: true,
},
});
if (!userTeamMembership) {
throw new Error('Local emulator user must be a member of the local emulator owner team');
} else {
console.log('Ensured emulator user is a member of emulator team');
}
await grantTeamPermission(internalPrisma, {
tenancy: internalTenancy,
teamId: LOCAL_EMULATOR_OWNER_TEAM_ID,
userId: LOCAL_EMULATOR_ADMIN_USER_ID,
permissionId: "team_admin",
});
}
console.log('Seeding complete!');
}