Skip to content
Merged
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
19 changes: 10 additions & 9 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
{
"permissions": {
"allow": [
"Bash(DATABASE_URL=\"postgresql://wheretf:wheretf@localhost:5432/wheretf_test\" npx vitest run)",
"WebSearch",
"WebFetch(domain:forum.figma.com)"
],
"additionalDirectories": [
"/home/nick/projects/nickydoes/wheretf/web/app/api/modules"
]
},
"hooks": {
"PreToolUse": [
{
Expand All @@ -11,14 +21,5 @@
]
}
]
},
"permissions": {
"allow": [
"Bash(DATABASE_URL=\"postgresql://wheretf:wheretf@localhost:5432/wheretf_test\" npx vitest run)",
"WebSearch"
],
"additionalDirectories": [
"/home/nick/projects/nickydoes/wheretf/web/app/api/modules"
]
}
}
4 changes: 0 additions & 4 deletions .claude/skills/committing.md

This file was deleted.

55 changes: 55 additions & 0 deletions specification/auth-roadmap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Auth & Multi-Tenancy Roadmap

Ordered sequence for landing authentication, authorization, and
multi-tenancy in WhereTF. Each phase blocks the next. Detailed scope
lives in [deployment.md](deployment.md#future-work--todos-so-the-deploy-system-knows-whats-coming)
and in the approved implementation plan
(`/home/nick/.claude/plans/linear-crafting-feigenbaum.md`); this file
is the index and the agreed ordering.

## External dependency — IdP

The homelab team deploys and operates the OIDC identity provider
(Authentik / Keycloak / Zitadel, TBD). This is **out of scope for
WhereTF**. WhereTF consumes the IdP as an OIDC client via
`AUTH_HOMELAB_ISSUER` and credentials. Until the IdP is live, the
homelab OIDC provider is optional (only registered when env vars are
set); customer credentials and dev impersonate work without it.

## Priority order (agreed)

1. **Authentication** — Auth.js v5, hybrid providers (homelab IdP OIDC
for staff/admin, email+password credentials for customers, dev
impersonate gated on non-production). JWT sessions (Auth.js v5
constraint when Credentials provider is in play). CSRF helper for
mutation routes.
2. **Authorization** — `orgs`, `user_orgs(role)` with roles
`owner | admin | member | viewer`. Per-request context hydrated by
middleware from session + active-org cookie. Enforcement
app-layer; Postgres RLS deferred until threat model justifies it.
3. **Multi-tenancy migration** — three-mode isolation model
(isolated / additive / open). Add `ownerOrgId` nullable → backfill
isolated tables to a default org → flip NOT NULL on isolated.
Additive tables keep nullable. Repo refactor so every method takes
`{ userId, orgId }`. Two-org isolation integration test per repo.
4. **Org switcher + signup UI** — signup creates user + default free
org. Org switcher in app shell. `/settings/org` for members /
invites / rename / transfer / delete.
5. **Plan gating** — `orgs.plan: 'free' | 'paid'` drives seat limit
and private-items gate. Billing integration and external API keys
are follow-on phases, not this roadmap.

## Why this order

- **1 → 2**: authorization needs an authenticated `userId` to scope by.
- **2 → 3**: the migration applies the authz model to existing data;
the model has to exist first.
- **3 → 4**: UI needs org context it can read and switch.
- **4 → 5**: plan gates check org plan at create/invite time; orgs
must exist and be switchable first.

## Gate between phases

Each phase produces an artifact reviewed before the next phase
starts. No combining phases, no parallel work across phases unless
explicitly agreed.
164 changes: 101 additions & 63 deletions specification/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,85 +201,123 @@ These are **planned, not implemented.** Deployment as described above
works without them. When any one of them lands, this doc and the
deployment system both get revisited.

### Identity provider on the homelab (TODO)
### Identity provider (external dependency)

The homelab currently has **no IdP deployed**. Before authentication
can ship, one has to be stood up and maintained.
The homelab team deploys and operates the OIDC IdP (Authentik /
Keycloak / Zitadel, TBD) — **out of scope for WhereTF**. WhereTF
consumes it as an OIDC client. The homelab OIDC provider is
registered only when `AUTH_HOMELAB_ISSUER`, `AUTH_HOMELAB_CLIENT_ID`,
and `AUTH_HOMELAB_CLIENT_SECRET` are set; customer credentials and
dev impersonate work without it.

Scope for that effort (separate project, separate plan cycle):
- Evaluate Authentik, Keycloak, Zitadel. Pick one.
- Deploy (Proxmox LXC or container), put behind Caddy, back with the
homelab Postgres VM or its own DB.
- Automate lifecycle with Ansible so it's reproducible.
- Add monitoring + backups alongside existing services.
Until the IdP is live, WhereTF runs with customer credentials only
and must not be internet-reachable.

Until that lands, WhereTF runs without auth and must not be internet-
reachable.
### Authentication

### Authentication (TODO)
Auth.js v5 (next-auth) in `web/`. Hybrid providers:

Depends on the IdP decision above. Plan:
- **Homelab IdP OIDC** — env-gated, for staff/admin federation.
- **Credentials** — email + password (bcryptjs), for customer
accounts. Signup endpoint creates a `users` row and a default owner
`orgs` row with `plan = 'free'`.
- **Dev impersonate** — registered only when
`NODE_ENV !== "production"`, lets any email through for local dev.

- **App side**: Auth.js (next-auth v5) with an OIDC provider. DB-backed
sessions — new `users`, `sessions` tables, users identified by
`(auth_provider, auth_subject)` for provider portability.
- **Dev**: local "impersonate" login gated on
`NODE_ENV !== "production"` so dev doesn't require the IdP to be
running.
- **CSRF**: Auth.js covers `/api/auth/*`; our mutation routes get a
shared helper.
**Session strategy: JWT.** Auth.js v5 requires JWT sessions when a
Credentials provider is in play. The JWT carries `sub` (user id);
per-request authz hydrates orgs and active role from the database.

### Authorization (TODO)
**CSRF**: Auth.js covers `/api/auth/*`; mutation routes call
`requireCsrf()` from `web/lib/auth/csrf.ts`.

Model: **org-scoped, role per user-org pair.**
### Authorization

- New tables: `orgs`, `user_orgs (user_id, org_id, role)`.
Roles: `owner | admin | member | viewer`.
Model: **org-scoped, role per user-org pair, three-mode data
isolation.**

- New tables:
- `users`, `accounts`, `sessions`, `verification_tokens` (Auth.js)
- `orgs (id, name, slug, plan: 'free' | 'paid', ...)`
- `user_orgs (user_id, org_id, role)`, roles
`owner | admin | member | viewer`, PK `(user_id, org_id)`
- Every authenticated request carries
`{ userId, currentOrgId, role }`, derived from session + an
"active org" cookie.
- **Per-org** tables: `modules`, `locations`, `inserts`, `assignments`,
`templates`, `template_versions`, `co_storability`.
- **Global** tables: `items`, `item_aspects`, `item_parameter_values`,
`aspects`, `parameters`, `standards`, `designations`, `categories`.
Items are deliberately shared — see project memory on the global
catalog vision.
- Enforcement starts application-layer (every repo method takes
`{ orgId }`), moves to Postgres RLS when the threat model justifies
the complexity.

### API access + rate limiting (TODO)
`{ userId, activeOrgId, role }`, hydrated in `web/middleware.ts`
from the session + `wtf-active-org` cookie (fallback = user's
first org).
- Isolation mode per table:
- **isolated** — `ownerOrgId NOT NULL`, strict filter
`ownerOrgId = :orgId`.
Tables: `modules`, `inserts`, `locations`, `assignments`,
`location_interfaces_accepted`, `transactions`.
- **additive** — `ownerOrgId` nullable. Read union
`(ownerOrgId IS NULL OR ownerOrgId = :orgId)`. Writes default to
the active org; writing a global row (`ownerOrgId = NULL`)
requires elevated permission.
Tables: `templates`, `template_versions`,
`template_version_interfaces_provided`,
`template_version_interfaces_accepted`, `items`, `item_aspects`,
`item_parameter_values`, `item_categories`, `item_standards`,
`co_storability`, `categories`, `parameter_definitions`,
`aspects`, `aspect_parameters`, `standards`,
`standard_parameters`, `standard_designations`,
`aspect_standards`, `interface_types`.
- **open** — no `ownerOrgId`, global only.
Tables: none at launch.
- **Private items** are additive `items` rows with
`ownerOrgId IS NOT NULL`. Free tier rejects private inserts; paid
tier allows. No separate table, no visibility enum.
- **Global catalog edit policy**: any signed-in user may create or
edit global rows. Every write lands in `transactions` with actor
`userId`. No moderation UI at launch.
- Enforcement app-layer in repositories. Postgres RLS is a future
refinement, not scheduled.

### Plan gating

`orgs.plan` drives two gates at launch:

1. **Seat limit** — free orgs reject a 2nd `user_orgs` insert.
2. **Private items** — free orgs reject `items` inserts with
`ownerOrgId != null`.

Parametric metadata is in-app readable for any signed-in user, free
or paid. External API access (API keys) is paid-only; enforcement
ships with the API keys phase.

Billing integration is a separate phase (product TBD). Until it
lands, `orgs.plan` is toggled by a platform admin (`users.is_admin`)
via an admin-only endpoint.

### API access + rate limiting (future phase)

Two surfaces:

1. **Internal** — session-cookie auth, CSRF for mutations.
Generous per-user limits (e.g. 60 rps burst, 300 rpm sustained).
2. **External** — API keys. New table `api_keys` with hashed keys,
scopes, per-key rate limits tied to the org's plan (subscription
hook).

Limiter: token bucket, sliding window. In-memory in dev; Redis (or
similar) in prod.
1. **Internal** — session auth + CSRF for mutations. Generous
per-user limits (e.g. 60 rps burst, 300 rpm sustained).
2. **External** — `api_keys` table with hashed keys, scopes, per-key
rate limits tied to `orgs.plan`. Paid-only.

Middleware lives in `web/middleware.ts`, intercepts `/api/*`, runs
auth + rate-limit + org hydration before the route handler. Exempts
`/api/health*`.
Limiter: token bucket, sliding window. In-memory in dev; Redis in
prod.

### Multi-tenancy migration (TODO, depends on auth + authz)
Middleware in `web/middleware.ts` intercepts `/api/*`, runs auth +
rate-limit + org hydration before the route handler. Exempts
`/api/health*` and `/api/auth/*`.

Execution order when picked up:
### Multi-tenancy migration (execution order)

1. Migration adds `users`, `orgs`, `user_orgs`, `sessions`. Adds
nullable `org_id` on every per-org table; backfills existing rows
to a "default" org; follow-up migration flips NOT NULL.
2. Repo refactor: every org-scoped method takes `{ orgId }`.
Integration test with two orgs enforces isolation per repo.
3. API middleware populates request-local org context.
4. Org switcher UI.
5. Items stay global. Write-heavy catalog paths audit into the
existing `transactions` table.
6. `orgs.plan` → rate limits + feature flags. Stripe (or whatever)
webhook updates it.
1. Add `ownerOrgId uuid` nullable to every additive + isolated table
(no FK yet).
2. Create `users`, `accounts`, `sessions`, `verification_tokens`,
`orgs`, `user_orgs`. Insert a default org and assign the current
single user as `owner`.
3. Backfill isolated tables to the default org. Additive tables stay
`NULL` — they become the global catalog.
4. Add FK `ownerOrgId → orgs.id` on every touched table. Flip
`NOT NULL` on isolated tables.
5. Repo refactor: every method takes `{ userId, orgId, ... }`.
6. Two-org isolation integration test per repo.

None of the above blocks the deploy work. Ship the image now; layer
auth + tenancy in when the IdP is ready.
auth + tenancy in when the org model lands.
2 changes: 1 addition & 1 deletion specification/project-intent.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ GUI and AI each own different concerns:

## Domain Concepts

- **Item** — what a thing *is*, independent of where it is. A type/category, not an instance or count. Thorough, structured item characterization is core to WhereTF — finding an item requires describing it well. Items are described by name and typed parameters organized by aspects (reusable parameter groups). See [item-taxonomy.md](item-taxonomy.md) for the classification system. Equivalent to a product in ERP. Items belong to WhereTF globally — as items are refined and improved, they benefit all users. Storage and assignments are per-org; items are shared. Future: private items as a paid feature.
- **Item** — what a thing *is*, independent of where it is. A type/category, not an instance or count. Thorough, structured item characterization is core to WhereTF — finding an item requires describing it well. Items are described by name and typed parameters organized by aspects (reusable parameter groups). See [item-taxonomy.md](item-taxonomy.md) for the classification system. Equivalent to a product in ERP. Items belong to WhereTF globally — as items are refined and improved, they benefit all users. Storage and assignments are per-org; items are shared. Private items (paid tier): an item row with `ownerOrgId` set is private to that org; free tier only creates global items.
- **Assignment** — connects an item to a location. Own entity, not a field on item or location. Either *placed* (specific leaf location, one per location unless co-storable) or *provisional* (at a location, position undetermined). Many assignments per item. Unassigned items and empty locations are both valid states.
- **Module** — a top-level, independent physical storage unit (cabinet, shelf, drawer unit). Never nested. Defines valid location path structures.
- **Template** — versioned blueprint for a storage product's layout (e.g. Plano Stowaway 3600 = 4×6 grid). Applied via inserts (receptacle locations) or directly (fixed locations). Instances pin to an applied version.
Expand Down
8 changes: 4 additions & 4 deletions web/app/api/aspects/[id]/items/route.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
import { aspectRepository } from "@/repositories/aspectRepository";
import { requireContext, errorResponse } from "@/lib/auth/route";

export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const ctx = await requireContext();
const { id } = await params;
const { searchParams } = new URL(request.url);
const limitStr = searchParams.get("limit");
const limit = limitStr ? parseInt(limitStr, 10) : 50;
const items = await aspectRepository.listItemsUsing({
orgId: ctx.activeOrgId,
aspectId: id,
limit: Number.isFinite(limit) && limit > 0 ? limit : 50,
});
return NextResponse.json({ items });
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : "Unexpected error" },
{ status: 500 }
);
return errorResponse(err);
}
}
29 changes: 14 additions & 15 deletions web/app/api/aspects/[id]/parameters/route.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
import { NextRequest, NextResponse } from "next/server";
import { aspectRepository } from "@/repositories/aspectRepository";
import { requireContext, errorResponse } from "@/lib/auth/route";

export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const ctx = await requireContext();
const { id } = await params;
const parameters = await aspectRepository.getParameters({ aspectId: id });
const parameters = await aspectRepository.getParameters({
orgId: ctx.activeOrgId,
aspectId: id,
});
return NextResponse.json({ parameters });
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : "Unexpected error" },
{ status: 500 },
);
return errorResponse(err);
}
}

Expand All @@ -22,19 +24,18 @@ export async function POST(
{ params }: { params: Promise<{ id: string }> },
) {
try {
const ctx = await requireContext();
const { id } = await params;
const body = await request.json();
const parameter = await aspectRepository.addParameter({
userId: ctx.userId,
orgId: ctx.activeOrgId,
aspectId: id,
...body,
});
return NextResponse.json({ parameter }, { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : "Unexpected error";
if (message.includes("not found")) {
return NextResponse.json({ error: message }, { status: 404 });
}
return NextResponse.json({ error: message }, { status: 400 });
return errorResponse(err);
}
}

Expand All @@ -43,18 +44,16 @@ export async function DELETE(
{ params }: { params: Promise<{ id: string }> },
) {
try {
const ctx = await requireContext();
const { id } = await params;
const { parameterDefinitionId } = await request.json();
await aspectRepository.removeParameter({
orgId: ctx.activeOrgId,
aspectId: id,
parameterDefinitionId,
});
return NextResponse.json({ success: true });
} catch (err) {
const message = err instanceof Error ? err.message : "Unexpected error";
if (message.includes("not found")) {
return NextResponse.json({ error: message }, { status: 404 });
}
return NextResponse.json({ error: message }, { status: 400 });
return errorResponse(err);
}
}
Loading
Loading