Initial Commit
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
# `@suaveplan/api-tenant-resolver-subdomain` — subdomain tenant resolver
|
||||
|
||||
**Package**: `@suaveplan/api-tenant-resolver-subdomain`
|
||||
**Status**: PROPOSED
|
||||
**Type**: new-package
|
||||
**Stage**: 8 — see umbrella `openspec/changes/api-plugin-foundation/`
|
||||
|
||||
## Why
|
||||
|
||||
Per design doc §6 step 4, multi-tenant deployments commonly serve each tenant at `<tenant>.api.example.com`. This adapter resolves the tenant from the request's host header, completing the v1 tenant-resolver chain alongside the header adapter.
|
||||
|
||||
## What changes
|
||||
|
||||
- ADDED: `createSubdomainTenantResolver({ baseDomain, validate, registry })` — reads `request.host` (or `X-Forwarded-Host` when configured), strips `baseDomain`, validates the remaining label, and returns `{ tenantId, source: "subdomain" }` or `null`.
|
||||
- ADDED: Multi-label support — `tenantLabelIndex` config selects which label is the tenant (default 0 — leftmost). Supports nested deployments (`<tenant>.<region>.api.example.com`).
|
||||
- ADDED: Forwarded-host trust — `trustForwardedHost` config defaults to `false` (don't trust client-supplied headers); when `true`, consults `trustedProxyCidrs` to validate the upstream IP.
|
||||
- ADDED: Strict mode parity with `api-tenant-resolver-header`.
|
||||
- ADDED: Conformance — passes `runTenantResolverConformance`.
|
||||
- ADDED: Error codes (`TENANT_SUBDOMAIN_*`): `INVALID`, `NOT_FOUND_IN_REGISTRY`, `BASE_DOMAIN_MISMATCH`, `FORWARDED_HOST_UNTRUSTED`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- TLS / cert provisioning per tenant — operator concern.
|
||||
- Reverse-DNS resolution — out of scope; we only look at the request host.
|
||||
|
||||
## Source mapping
|
||||
|
||||
Genesis-original. Source of design intent: `docs/superpowers/specs/2026-05-13-api-plugin-foundation-design.md`.
|
||||
|
||||
| Requirement | Design doc section |
|
||||
|---|---|
|
||||
| Tenant resolver chain | §6 step 4 |
|
||||
|
||||
## Verification approach
|
||||
|
||||
- `src/resolver/resolver.test.ts` — `acme.api.example.com` → `acme`; mismatched base domain → strict raises `TENANT_SUBDOMAIN_BASE_DOMAIN_MISMATCH`; multi-label index.
|
||||
- `src/forwarded/forwarded.test.ts` — trusted-proxy CIDR check; untrusted proxy ignored.
|
||||
- `src/resolver/resolver.spec-conformance.test.ts` — runs `runTenantResolverConformance`.
|
||||
- `tests/dep-graph.test.ts`.
|
||||
|
||||
## Migration impact
|
||||
|
||||
None — additive only. `api-host` wires this when `host.tenant.resolvers` includes `"subdomain"`.
|
||||
@@ -0,0 +1,78 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: api-tenant-resolver-subdomain (api-tenant-resolver-subdomain)
|
||||
|
||||
**Package**: `@suaveplan/api-tenant-resolver-subdomain`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Resolves tenant from request host subdomain
|
||||
|
||||
The package SHALL expose `createSubdomainTenantResolver({ baseDomain, validate, registry })` that reads `request.host`, strips `baseDomain`, validates the remaining label, and returns `{ tenantId, source: "subdomain" }` when valid.
|
||||
|
||||
#### Scenario: leftmost label resolves as tenant
|
||||
|
||||
- WHEN `request.host = "acme.api.example.com"` AND `baseDomain = "api.example.com"`
|
||||
- THEN the resolver returns `{ tenantId: "acme", source: "subdomain" }`
|
||||
|
||||
**Verified by:** `src/resolver/resolver.test.ts::"leftmost label resolves as tenant"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Base-domain mismatch handled per strict mode
|
||||
|
||||
The package SHALL raise `TENANT_SUBDOMAIN_BASE_DOMAIN_MISMATCH` in strict mode when the host does not end with `baseDomain`. In non-strict mode it returns `null`.
|
||||
|
||||
#### Scenario: mismatched base domain raises in strict
|
||||
|
||||
- WHEN `request.host = "acme.evil.example.com"` AND `baseDomain = "api.example.com"` AND `strict: true`
|
||||
- THEN the resolver raises `TENANT_SUBDOMAIN_BASE_DOMAIN_MISMATCH`
|
||||
|
||||
**Verified by:** `src/resolver/resolver.test.ts::"mismatched base domain raises in strict"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Multi-label deployments via `tenantLabelIndex`
|
||||
|
||||
The package SHALL accept `tenantLabelIndex` (default 0) to support hosts like `<tenant>.<region>.api.example.com`.
|
||||
|
||||
#### Scenario: middle-label deployment
|
||||
|
||||
- WHEN `request.host = "acme.eu.api.example.com"` AND `tenantLabelIndex = 0` AND `baseDomain = "api.example.com"`
|
||||
- THEN tenant is `acme` (label 0 of `acme.eu`)
|
||||
|
||||
**Verified by:** `src/resolver/resolver.test.ts::"multi-label deployment resolves by index"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `X-Forwarded-Host` trust requires CIDR allowlist
|
||||
|
||||
The package SHALL only consult `X-Forwarded-Host` when `trustForwardedHost: true` AND the upstream IP matches `trustedProxyCidrs`. Untrusted upstream raises `TENANT_SUBDOMAIN_FORWARDED_HOST_UNTRUSTED` in strict mode.
|
||||
|
||||
#### Scenario: untrusted proxy ignored
|
||||
|
||||
- WHEN `X-Forwarded-Host` is set AND the upstream IP is not in `trustedProxyCidrs` AND `strict: true`
|
||||
- THEN the resolver raises `TENANT_SUBDOMAIN_FORWARDED_HOST_UNTRUSTED`
|
||||
|
||||
#### Scenario: trusted proxy honoured
|
||||
|
||||
- WHEN the upstream IP matches `trustedProxyCidrs` AND `X-Forwarded-Host` is well-formed
|
||||
- THEN the forwarded host is used to resolve the tenant
|
||||
|
||||
**Verified by:** `src/forwarded/forwarded.test.ts::"untrusted proxy raises FORWARDED_HOST_UNTRUSTED"` AND `src/forwarded/forwarded.test.ts::"trusted proxy honours forwarded host"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Passes `runTenantResolverConformance`
|
||||
|
||||
The package SHALL pass `runTenantResolverConformance` from `@suaveplan/api-tenant-provider/testing`.
|
||||
|
||||
#### Scenario: conformance passes
|
||||
|
||||
- WHEN `runTenantResolverConformance("subdomain", makeSubdomainResolver)` runs
|
||||
- THEN every harness `it(...)` passes
|
||||
|
||||
**Verified by:** `src/resolver/resolver.spec-conformance.test.ts::"subdomain resolver passes runTenantResolverConformance"`
|
||||
@@ -0,0 +1,63 @@
|
||||
# Tasks — `@suaveplan/api-tenant-resolver-subdomain`
|
||||
|
||||
## 0. Pre-flight (non-negotiable)
|
||||
|
||||
- [ ] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
|
||||
- [ ] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
|
||||
- [ ] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read design doc (§6 step 4)
|
||||
- [x] 1.2 Read `openspec/AGENTS.md` Sections 13 and 14
|
||||
- [x] 1.3 Draft `proposal.md`
|
||||
- [x] 1.4 Draft `specs/api-tenant-resolver-subdomain/spec.md`
|
||||
- [x] 1.5 Every requirement carries `**Verified by:**`
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [ ] 2.1 Scaffold `packages/api/api-tenant-resolver-subdomain/`
|
||||
- [ ] 2.2 `src/resolver/` — `createSubdomainTenantResolver`
|
||||
- [ ] 2.3 `src/forwarded/` — trusted-proxy CIDR enforcement
|
||||
- [ ] 2.4 `src/validate/` — label validator
|
||||
- [ ] 2.5 `src/error-codes.ts`
|
||||
- [ ] 2.6 `src/index.ts` — explicit named re-exports
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- [ ] 3.1 `src/resolver/resolver.test.ts`
|
||||
- [ ] 3.2 `src/forwarded/forwarded.test.ts`
|
||||
- [ ] 3.3 `src/resolver/resolver.spec-conformance.test.ts`
|
||||
- [ ] 3.4 `tests/dep-graph.test.ts`
|
||||
- [ ] 3.5 Every `it(...)` carries `{ timeout: 5000 }`
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [ ] 4.1 Sibling `.md` per implementation `.ts`
|
||||
- [ ] 4.2 `README.md` `## Modules`
|
||||
- [ ] 4.3 Update `CLAUDE.md` package listing
|
||||
|
||||
## 5. Pre-archive verification (the 5-gate triple)
|
||||
|
||||
- [ ] 5.1 `bun run check`
|
||||
- [ ] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/api-tenant-resolver-subdomain`
|
||||
- [ ] 5.3 `bunx turbo test:coverage --filter=@suaveplan/api-tenant-resolver-subdomain` — 100/100/100/100
|
||||
- [ ] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/api-tenant-resolver-subdomain`
|
||||
- [ ] 5.5 `bunx turbo build --filter=@suaveplan/api-tenant-resolver-subdomain`
|
||||
|
||||
## 6. Pre-archive 7-row checklist
|
||||
|
||||
- [ ] 6.1 Generics threaded
|
||||
- [ ] 6.2 Zod schemas wired at host-config ingestion
|
||||
- [ ] 6.3 Every `TENANT_SUBDOMAIN_*` code thrown by a production path
|
||||
- [ ] 6.4 Strict-mode: invalid label → 400 in strict, → null in non-strict; X-Forwarded-Host only trusted from CIDRs
|
||||
- [ ] 6.5 OTel `api.tenant.resolves_total{resolver="subdomain"}` asserted
|
||||
- [ ] 6.6 Lifecycle dedup — resolver is pure
|
||||
- [ ] 6.7 No retroactive tick
|
||||
|
||||
## 7. Archive
|
||||
|
||||
- [ ] 7.1 Fold spec delta into `openspec/specs/api-tenant-resolver-subdomain/spec.md`
|
||||
- [ ] 7.2 Move folder to archive
|
||||
- [ ] 7.3 Tick `8.16` in umbrella tasks
|
||||
- [ ] 7.4 Refresh `MANIFEST.md`
|
||||
Reference in New Issue
Block a user