Skip to content
Draft
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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,6 @@ INVITE_TOKEN_EXPIRATION_MS=
# Not needed locally. Used by the sanitize-db script in the development.yaml workflow.
NON_PROD_SYNC_PASSWORD=

# Connection string for the Vercel Edge Config used to store tenants for middleware.
VERCEL_EDGE_CONFIG=

# API token for Vercel. Primarily used to write values to the Vercel Edge Config but could be used for other operations.
VERCEL_TOKEN=

# Vercel Team ID. Access on the team's settings page.
VERCEL_TEAM_ID=

# Local flags: these are optional flags you can set locally to do things like run production builds, enable more
# logging for Next.js caching, etc.
# Commented out by default.
Expand Down
50 changes: 26 additions & 24 deletions __tests__/server/getHostnameFromTenant.server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,84 +28,86 @@ describe('server-side utilities: getHostnameFromTenant', () => {
})

it('returns custom domain for production tenants', () => {
// Use a valid tenant slug from AVALANCHE_CENTERS
PRODUCTION_TENANTS.length = 0
PRODUCTION_TENANTS.push('production-tenant')
PRODUCTION_TENANTS.push('nwac')

// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const tenant = {
slug: 'production-tenant',
customDomain: 'productiondomain.com',
slug: 'nwac',
customDomain: 'nwac.us',
} as Tenant

const result = getHostnameFromTenant(tenant)
expect(result).toBe('productiondomain.com')
expect(result).toBe('nwac.us')
})

it('returns subdomain format for non-production tenants', () => {
// Only nwac is a production tenant; sac is valid but not in PRODUCTION_TENANTS
PRODUCTION_TENANTS.length = 0
PRODUCTION_TENANTS.push('production-tenant')
PRODUCTION_TENANTS.push('nwac')

// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const tenant = {
slug: 'development-tenant',
customDomain: 'productiondomain.com',
slug: 'sac',
customDomain: 'sierraavalanchecenter.org',
} as Tenant

const result = getHostnameFromTenant(tenant)
expect(result).toBe('development-tenant.envvar.localhost:3000')
expect(result).toBe('sac.envvar.localhost:3000')
})

it('handles multiple production tenants correctly', () => {
PRODUCTION_TENANTS.length = 0
PRODUCTION_TENANTS.push('tenant1', 'tenant2', 'tenant3')
PRODUCTION_TENANTS.push('nwac', 'sac', 'uac')

// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const tenant1: Tenant = {
slug: 'tenant1',
customDomain: 'tenant1productiondomain.com',
slug: 'nwac',
customDomain: 'nwac.us',
} as Tenant

// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const tenant2: Tenant = {
slug: 'tenant2',
customDomain: 'tenant2productiondomain.com',
slug: 'sac',
customDomain: 'sierraavalanchecenter.org',
} as Tenant

// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const nonProductionTenant: Tenant = {
slug: 'non-production',
customDomain: 'tenant3productiondomain.com',
slug: 'btac',
customDomain: 'bridgertetonavalanchecenter.org',
} as Tenant

expect(getHostnameFromTenant(tenant1)).toBe('tenant1productiondomain.com')
expect(getHostnameFromTenant(tenant2)).toBe('tenant2productiondomain.com')
expect(getHostnameFromTenant(nonProductionTenant)).toBe('non-production.envvar.localhost:3000')
expect(getHostnameFromTenant(tenant1)).toBe('nwac.us')
expect(getHostnameFromTenant(tenant2)).toBe('sierraavalanchecenter.org')
expect(getHostnameFromTenant(nonProductionTenant)).toBe('btac.envvar.localhost:3000')
})

it('handles empty production tenants list', () => {
PRODUCTION_TENANTS.length = 0

// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const tenant: Tenant = {
slug: 'any-tenant',
customDomain: 'custom.example.com',
slug: 'nwac',
customDomain: 'nwac.us',
} as Tenant

const result = getHostnameFromTenant(tenant)
expect(result).toBe('any-tenant.envvar.localhost:3000')
expect(result).toBe('nwac.envvar.localhost:3000')
})

it('handles tenant with empty custom domain by falling back to tenant subdomain', () => {
PRODUCTION_TENANTS.length = 0
PRODUCTION_TENANTS.push('production-tenant')
PRODUCTION_TENANTS.push('nwac')

// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const tenant: Tenant = {
slug: 'production-tenant',
slug: 'nwac',
customDomain: '',
} as Tenant

const result = getHostnameFromTenant(tenant)
expect(result).toBe('production-tenant.envvar.localhost:3000')
expect(result).toBe('nwac.envvar.localhost:3000')
})
})
3 changes: 0 additions & 3 deletions consistent-type-assertions.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,7 @@ src/collections/Users/components/InviteUserDrawer.tsx
src/collections/Users/components/inviteUserAction.ts
src/collections/Users/components/resendInviteActions.ts
src/components/Header/utils.ts
src/components/TenantSelector/TenantSelector.client.tsx
src/endpoints/seed/index.ts
src/endpoints/seed/upsert.ts
src/globals/Diagnostics/actions/revalidateCache.ts
src/middleware.ts
src/services/vercel.ts
src/utilities/removeNonDeterministicKeys.ts
2 changes: 1 addition & 1 deletion docs/decisions/008-edge-config-tenant-lookup.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Date: 2025-07-26

Status: accepted
Status: superseded by [013-hardcoded-tenant-lookup.md](./013-hardcoded-tenant-lookup.md)

Supersedes: [007-dynamic-tenants-middleware.md](./007-dynamic-tenants-middleware.md)

Expand Down
80 changes: 80 additions & 0 deletions docs/decisions/013-hardcoded-tenant-lookup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Hardcoded Tenant Lookup

Date: 2026-01-22

Status: accepted

Supersedes: [008-edge-config-tenant-lookup.md](./008-edge-config-tenant-lookup.md)

## Context

We're essentially migrating back to the approach we used before [007-dynamic-tenants-middleware.md](./007-dynamic-tenants-middleware.md) which is to have a hardcoded tenant list. We moved to a dynamic tenant lookup because we were using the multi-tenant plugin from Payload at that point which needed to use the tenants collection's id for the tenant cookie.

Since we [moved away from the multi-tenant plugin](./010-remove-multi-tenant-plugin.md), we can use a hardcoded list of all possible avalanche centers and use the slug for the `payload-tenant` cookie because we control the code. The biggest issue that [007-dynamic-tenants-middleware.md](./007-dynamic-tenants-middleware.md) accomplished was to avoid issues where ids were different between environments due to order in which they were created / the tenants that were actually created in a given environment. This isn't an issue when using the slug.

We got a little too complicated with the Edge Config lookup. The Edge Config-based tenant lookup approach had several drawbacks:

1. **External dependency**: Required Vercel Edge Config service and API tokens
2. **Multiple failure modes**: Edge Config unavailable → cached API fallback → database query
3. **Sync complexity**: Hooks needed to update Edge Config on tenant changes
4. **Middleware latency**: Async lookups added latency to every request
5. **Cookie stored tenant ID**: Required ID-to-slug conversions for URL routing
6. **Seemed to have issues with static route generation**: because of the Edge Config call

The list of US avalanche centers is finite and rarely changes. New centers joining are infrequent events that can be handled via code deployment. Requiring a code change for a new center being created is reasonable and we already have to [make a code change for when tenants go to production](../onboarding.md).

## Decision

Replace Edge Config with a **hardcoded list of all US avalanche center slugs** directly in the codebase.

### Key Changes

1. **Single source of truth**: `src/utilities/tenancy/avalancheCenters.ts` contains all valid tenant slugs and custom domains
2. **Synchronous middleware**: Tenant matching is now purely synchronous with no external lookups
3. **Slug-based cookies**: The `payload-tenant` cookie now stores the tenant slug (e.g., `nwac`) instead of numeric ID
4. **Database relationships unchanged**: Tenant relationships still use numeric IDs internally

### Data Flow

```
Request → Middleware matches slug from subdomain/domain
→ Sets cookie: payload-tenant=nwac
Admin/API reads slug from cookie
→ Queries tenant by slug when ID is needed
→ Uses ID for relationship operations
```

### Files Removed

- `src/services/vercel.ts` - Edge Config API integration
- `src/utilities/tenancy/getTenants.ts` - Async tenant fetching
- `src/collections/Tenants/hooks/updateEdgeConfig.ts` - Edge Config sync hooks
- `src/collections/Tenants/endpoints/cachedPublicTenants.ts` - Cached API fallback
- `src/utilities/tenancy/getCollectionIDType.ts` - ID type helper
- `src/utilities/tenancy/getTenantIdFromCookie.ts` - Replaced by slug-based function

### Environment Variables Removed

- `VERCEL_EDGE_CONFIG`
- `VERCEL_TOKEN`
- `VERCEL_TEAM_ID`

## Consequences

**Benefits:**

- **Zero external dependencies** for tenant resolution
- **Faster middleware** - synchronous slug matching instead of async API calls
- **Simpler architecture** - no hooks, no fallbacks, no caching layers
- **Type safety** - `ValidTenantSlug` type ensures only valid slugs are used

**Trade-offs:**

- **Code deployment required** to add new avalanche centers
- **Custom domain changes** require code deployment (this was the case before too due to whitelisting for images in the `next.config.ts`)

**Migration notes:**

- Existing `payload-tenant` cookies with numeric IDs will be replaced with slugs on next visit
- The Tenants collection `slug` field is now a select field with predefined options
Loading
Loading