# @vantageos/cloud-identity

Building blocks for multi-tenant authorization: decide which tenant a caller
belongs to, and keep every other tenant's rows out of the response.

If your service holds data for several customers behind one deployment, you
answer the same four questions on every request — who is calling, which tenant
they belong to, which rows they may see, and whether their token is genuine.
This package answers them once, in plain functions with no framework attached,
so each service does not re-implement (and re-misimplement) its own version.

It depends on no web framework, router or database, and runs anywhere
JavaScript runs.

## Install

```bash
npm install @vantageos/cloud-identity
```

Node 18 or later. ESM only.

## Quick start

```js
import { requireTenantId, scopeFilterList } from "@vantageos/cloud-identity";

// 1. Which tenant is this caller in? Throws if the answer is "none".
const tenantId = requireTenantId({
  kind: "session",
  identity: { userId: "user_123", orgId: "org_abc" },
});

// 2. Which of these rows may they see?
const ctx = {
  fromAllowList: ["reports"],
  namespaceReadPrefixes: ["team/finance"],
  namespaceWritePrefixes: [],
};

const rows = [
  { createdBy: "reports", namespace: "team/finance" },    // kept
  { createdBy: "someone-else", namespace: "team/legal" }, // dropped
];

console.log(tenantId);                    // "org_abc"
console.log(scopeFilterList(ctx, rows));  // only the first row
```

Every symbol is available from the package root. Subpath imports
(`@vantageos/cloud-identity/scope-filter` and friends) also work if you prefer
to be explicit about where something comes from.

## Both caller paths, one contract

A right is never granted by an absent argument. `requireTenantId` refuses by
default — a missing session, a missing organization, or a missing
bearer-resolved workspace all throw; there is no branch that falls through to
"full access" when tenant information is absent. The same function resolves
both entry paths a VantagePeers Cloud request can arrive on:

```ts
import {
  requireTenantId,
  decodeUnverifiedBearer,
  type TenantSource,
} from "@vantageos/cloud-identity";

async function resolveTenant(req: {
  session?: { orgId?: string | null } | null;
  bearerToken?: string;
}): Promise<string> {
  const source: TenantSource = req.session
    ? { kind: "session", identity: req.session }
    : {
        // decodeUnverifiedBearer only decodes the shape — verify the token's
        // signature (or look it up against your own store) before trusting
        // any field on the payload. See "What this package does not do".
        kind: "bearer",
        context: await decodeUnverifiedBearer(req.bearerToken!),
      };

  // Throws for BOTH paths if no tenant is attached — never returns a
  // "default" tenant id when one is missing.
  return requireTenantId(source);
}

// Human path: a signed-in user with an active organization.
await resolveTenant({ session: { orgId: "org_abc" } }); // -> "org_abc"

// Machine path: a caller presenting an already-resolved bearer token.
await resolveTenant({ bearerToken: someBase64Token }); // -> the token's workspaceId

// Either path with nothing attached throws instead of granting access.
await resolveTenant({ session: null }); // throws Error("Unauthenticated: no session.")
```

## What each function does

**Tenant resolution and membership**

- `requireTenantId(source)` — returns the caller's tenant id, or throws if
  there is none. It accepts both entry paths behind one contract:
  `{ kind: "session", identity }` for a signed-in human, and
  `{ kind: "bearer", context }` for a machine caller. A missing session, a
  missing organization and an empty organization id are all refusals, never a
  default.
- `getEffectiveTenantId(ctx, args)` — resolves which tenant a request should
  act on when a caller may legitimately act on more than one.

**Row filtering**

- `passesScopeFilter(ctx, row)` — true when this row is visible to this
  caller.
- `scopeFilterList(ctx, rows)` — the subset of rows the caller may see.
- `scopeFilterGet(ctx, row)` — the row, or `null` if the caller may not see it.
- `isMasterScope(ctx)` / `isWildcardScope(ctx)` — true when the caller holds
  unrestricted access. Useful for skipping filtering you know is pointless;
  never as a substitute for it.

Visibility is granted two ways: the row's `createdBy` appears in the caller's
`fromAllowList`, or the row's `namespace` sits under one of the caller's
`namespaceReadPrefixes`. Prefixes match on a path boundary, so a caller allowed
`team/finance` does not thereby see `team/finance-archive`.

**Token validation**

- `validateMasterBearer(header, secret)` — compares an `Authorization: Bearer`
  header against a shared secret in constant time, and reports *why* it failed:
  header absent, header malformed, or value mismatched.
- `timingSafeEqual(a, b)` — constant-time byte comparison, if you need to build
  your own check.

**Shapes**

Zod schemas and their inferred TypeScript types for workspaces, members,
roles and tenant context: `workspaceSchema` (type `Workspace`),
`workspaceMemberSchema` (type `WorkspaceMember`), `workspaceRoleSchema`
(type `WorkspaceRole`), `tenantContextSchema` (type `TenantContext`) — plus
`ScopeViolationError` (payload type `ScopeViolationPayload`), for refusals you
want to catch by type, and `BearerPayload`, the type returned by
`decodeUnverifiedBearer`.

**Supporting types** (no runtime code — import with `import type`)

- `OAuthCtx` — the context object every scope-filter function above requires:
  `{ fromAllowList, namespaceReadPrefixes, namespaceWritePrefixes, scope? }`.
- `ScopeProfile`, `NamespacePrefix`, `FromAllowListEntry` — the field types
  that make up `OAuthCtx`.
- `ValidateMasterBearerResult` — the return type of `validateMasterBearer`.
- `ScopeFilterable` — the minimal row shape (`{ createdBy?, namespace? }`)
  accepted by `passesScopeFilter` / `scopeFilterList` / `scopeFilterGet`.
- `SessionIdentity` — the human-path shape `requireTenantId` accepts under
  `{ kind: "session", identity }` (see "Both caller paths, one contract").
- `TenantSource` — the discriminated union `requireTenantId` accepts:
  `{ kind: "session", identity }`, `{ kind: "bearer", context }`, or
  `{ kind: "self-host", tenantId }`.
- `DeploymentMode` — the named `"cloud" | "self-host"` literal union (0.4.0).

**Deployment mode and the human path (0.4.0, additive)**

- `DeploymentMode` (`"cloud" | "self-host"`) names the two ways this package
  can be run. `cloud` keeps `requireTenantId`'s existing session/bearer
  behaviour exactly as-is — organization/workspace REQUIRED, fail-closed.
  `self-host` is single-tenant: pass `{ kind: "self-host", tenantId }` to
  `requireTenantId`, and it returns `tenantId` when it is a non-empty string.
  The self-host tenant id must be DECLARED via configuration — if you select
  self-host mode without configuring a tenant id, `requireTenantId` throws
  rather than returning a default. A right is never granted by absence,
  whichever mode you are in.

  ```ts
  import { requireTenantId } from "@vantageos/cloud-identity";

  // self-host: the tenant id is configured explicitly, once, at startup.
  requireTenantId({ kind: "self-host", tenantId: "the-one-tenant" });
  // -> "the-one-tenant"

  requireTenantId({ kind: "self-host", tenantId: undefined });
  // -> throws: "No tenant id configured for self-host mode..."
  ```

- `normalizeVerifiedHumanSession(session)` — **normalizes, does not
  authenticate.** Maps an already-*verified* (not merely well-shaped),
  framework-agnostic Clerk session object (`{ orgId, userId, orgRole }`) to
  `{ tenant, subject, role }`. This package never imports a Clerk SDK; the
  caller MUST verify the session upstream (e.g. Clerk's server-side `auth()`)
  and pass in the already-verified object — never an unverified,
  client-supplied object such as `req.body`, which would make the caller's
  input the trusted source of tenant/subject/role. Refuses (throws) when the
  session has no organization, no user id, or an org role this package does
  not recognize — never defaults silently; these are shape checks, not a
  verification step.
- `humanAccountRoleSchema` (type `HumanAccountRole`) — the new
  `"owner" | "admin" | "member" | "client"` role union returned by
  `normalizeVerifiedHumanSession`. Distinct from `workspaceRoleSchema`
  (`Admin | Editor | Viewer`) — one is an organization-account role, the
  other is workspace membership; they are never conflated.
- `ClerkSessionLike` — the minimal input shape `normalizeVerifiedHumanSession` accepts:
  `{ orgId?, userId?, orgRole? }`.
- `ResolvedHumanIdentity` — the `{ tenant, subject, role }` return type of
  `normalizeVerifiedHumanSession`.

  ```ts
  import { normalizeVerifiedHumanSession } from "@vantageos/cloud-identity";

  normalizeVerifiedHumanSession({
    orgId: "org_abc",
    userId: "user_123",
    orgRole: "org:admin",
  });
  // -> { tenant: "org_abc", subject: "user_123", role: "admin" }
  ```

## What this package does not do

Worth reading before you rely on it.

- **`decodeUnverifiedBearer` decodes a token. It does not verify one.** The
  name is literal. It reads the payload out of a bearer token and checks its
  shape; it does not check a signature, so the contents are whatever the caller
  chose to put there. Treating its output as trusted is a vulnerability, not a
  shortcut. Verify the token — a signed JWT, or a lookup against your own store
  — before you believe any field in it.
- **This is not an authentication system.** There is no user store, no login,
  no session issuance. It takes an identity you have already established and
  tells you what that identity may reach.
- **It does not talk to your database.** `scopeFilterList` filters rows you
  have already fetched. If fetching them was itself expensive or unsafe, filter
  earlier, in your query.

## Upgrading to 0.4.0

**This release is purely additive — nothing you already call changes.** Every
0.3.0 export keeps its exact behaviour; 0.4.0 only adds two primitives, so an
upgrade from 0.3.0 compiles and runs unchanged until you choose to adopt them.

What is new:

- **A named deployment mode.** `requireTenantId` gains a `self-host` source
  alongside `session` and `bearer`. `cloud` is unchanged — an org-less
  identity is still refused. `self-host` is single-tenant and returns the
  tenant you **declare**; it throws when none is configured, never inferring
  one from absence.

  ```js
  import { requireTenantId } from "@vantageos/cloud-identity";

  // cloud (unchanged): refuses when the session has no org
  requireTenantId({ kind: "session", identity });
  // self-host: returns the DECLARED tenant, throws if it is empty
  requireTenantId({ kind: "self-host", tenantId: process.env.TENANT_ID });
  ```

- **A human-path normalizer.** `normalizeVerifiedHumanSession` maps an
  **already-verified** Clerk-shaped session into `{ tenant, subject, role }`
  (`role` ∈ `owner | admin | member | client`).

  ```js
  import { normalizeVerifiedHumanSession } from "@vantageos/cloud-identity";

  // session MUST already be verified upstream, e.g. Clerk auth() server-side:
  const { tenant, subject, role } = normalizeVerifiedHumanSession(session);
  ```

  ⚠️ It **normalizes, it does not authenticate.** Passing an unverified,
  client-controlled object (a `req.body`, a query payload) makes that object
  the trusted source of tenant, subject and role — a privilege escalation.
  Verify the session upstream first; only pass the object your provider
  already verified. (Same discipline as `decodeUnverifiedBearer`.)

Nothing to change on upgrade, nothing removed. Adopt the new primitives when
you need them.

## Upgrading to 0.3.0

**This release changes a default, and it breaks compilation on purpose.**

Before 0.3.0, calling a scope-filter function without an authorization context
returned `true` — no context meant full access. A caller who simply forgot to
pass the context received every row, and no warning. From 0.3.0 the context is
required: omitting it is a type error, and passing `null` or `undefined`
throws.

The breakage is deliberate, and it is the point. A caller that stops compiling
is a caller that has been told. A caller that still compiles and quietly
returns different rows is an incident nobody attributes to the upgrade.

What to do:

- If the call site has a real authorization context, pass it. This is almost
  always the right fix, and the context is usually already in scope.
- If the call site genuinely means "unrestricted", pass the exported constant
  `LEGACY_WILDCARD_CTX` by name. It reproduces the old behaviour exactly. It
  has to be written out, because an intentional bypass belongs in a code review
  and an accidental one should not be possible.

```js
import { scopeFilterList, LEGACY_WILDCARD_CTX } from "@vantageos/cloud-identity";

scopeFilterList(LEGACY_WILDCARD_CTX, rows); // explicit, unrestricted
```

`requireTenantId` is also new in 0.3.0. Nothing existing calls it, so it breaks
nothing.

Version history is in [CHANGELOG.md](./CHANGELOG.md).

## License

FSL-1.1-Apache-2.0. See [LICENSE](./LICENSE).

## Issues

https://github.com/vantageos-agency/cloud-identity/issues
