Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
export {
requireTenant,
type TenantPrincipal,
} from "./require-tenant.js";
export { stripUndefined } from "./strip-undefined.js";

View File

@@ -0,0 +1,69 @@
# routes/shared/require-tenant — Principal → tenantId guard
## Purpose
Centralised guard that extracts and validates the request `Principal`
on every `/contacts` and `/groups` handler. Returns a typed
`{ id, tenantId }` pair when the principal is well-formed (non-empty
`tenantId`) and throws `ContactsApiError(UNAUTHORIZED)` mapped to HTTP
401 otherwise.
Replaces the upstream port's repeated four-line `if (!user?.tenantId)
return c.json(...)` pattern (and its `as any` cast on the `user`
context slot). One helper, one error path, one tested invariant.
## Features
- Structural-typing the principal: any `{ id?, tenantId? }` shape is
accepted, so the helper does not depend on a specific Better-Auth
configuration.
- Single, typed error path via `ContactsApiError`.
- Defaults missing `id` to `""` to keep handlers from leaking
`undefined` into downstream calls.
## Usage: Basic
```ts
import { requireTenant } from "@suaveplan/api-plugin-contacts";
async function handler(c: Context) {
const { id, tenantId } = requireTenant(c);
return c.json({ ok: true, tenantId });
}
```
## Usage: Advanced
```ts
import { requireTenant } from "@suaveplan/api-plugin-contacts";
async function listContactsHandler(c: Context) {
const { tenantId } = requireTenant(c);
const repo = c.get("contactRepository");
if (repo === undefined) {
throw new Error("contactRepository missing");
}
const result = await repo.list({ tenantId, limit: 50 });
return c.json({ success: true as const, data: result });
}
```
## API Reference
- `interface TenantPrincipal` — `{ readonly id: string; readonly tenantId: string }`.
- `requireTenant(c: Context): TenantPrincipal` — extracts + validates;
throws `ContactsApiError(CONTACTS_API.UNAUTHORIZED)` with HTTP-401
context on failure.
## Implementation Notes
- The helper reads `c.get("user")` typed as the universal Principal
shape via the `@suaveplan/api-plugin-auth` module augmentation; this
package does not re-augment Hono's variable map for `user` (the auth
plugin owns that slot).
- The four guards (null, undefined, non-string tenantId, empty
tenantId) are exercised by separate `it(...)` blocks so every branch
is covered.
- `id` defaulting to `""` when missing keeps later GDPR audit logs
from carrying `"undefined"` strings; downstream code that needs a
real id should re-check.

View File

@@ -0,0 +1,81 @@
import "@suaveplan/api-plugin-auth";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { Hono } from "hono";
import { CONTACTS_API_CODES } from "../../error-codes/error-codes.js";
import { requireTenant } from "./require-tenant.js";
describe("requireTenant", () => {
it("returns id + tenantId when principal is well-formed", {
timeout: 5000,
}, async () => {
const app = new Hono();
app.get("/x", (c) => {
c.set("user", { id: "u1", tenantId: "t1" } as never);
const t = requireTenant(c);
return c.json(t);
});
const res = await app.request("/x");
expect(await res.json()).toEqual({ id: "u1", tenantId: "t1" });
});
it("throws UNAUTHORIZED when principal absent", {
timeout: 5000,
}, async () => {
const app = new Hono();
app.get("/x", (c) => {
return c.json(requireTenant(c));
});
app.onError((err, c) =>
c.json({ code: (err as { code?: string }).code }, 500)
);
const res = await app.request("/x");
const body = (await res.json()) as { code: string };
expect(body.code).toBe(CONTACTS_API_CODES.UNAUTHORIZED);
});
it("throws UNAUTHORIZED when tenantId missing", {
timeout: 5000,
}, async () => {
const app = new Hono();
app.get("/x", (c) => {
c.set("user", { id: "u1" } as never);
return c.json(requireTenant(c));
});
app.onError((err, c) =>
c.json({ code: (err as { code?: string }).code }, 500)
);
const res = await app.request("/x");
expect(((await res.json()) as { code: string }).code).toBe(
CONTACTS_API_CODES.UNAUTHORIZED
);
});
it("throws UNAUTHORIZED when tenantId empty string", {
timeout: 5000,
}, async () => {
const app = new Hono();
app.get("/x", (c) => {
c.set("user", { id: "u1", tenantId: "" });
return c.json(requireTenant(c));
});
app.onError((err, c) =>
c.json({ code: (err as { code?: string }).code }, 500)
);
const res = await app.request("/x");
expect(((await res.json()) as { code: string }).code).toBe(
CONTACTS_API_CODES.UNAUTHORIZED
);
});
it("defaults missing principal.id to empty string", {
timeout: 5000,
}, async () => {
const app = new Hono();
app.get("/x", (c) => {
c.set("user", { tenantId: "t1" } as never);
return c.json(requireTenant(c));
});
const res = await app.request("/x");
expect(await res.json()).toEqual({ id: "", tenantId: "t1" });
});
});

View File

@@ -0,0 +1,50 @@
/**
* Tenant-extraction helper. Reads the `Principal` from `c.get("user")`
* and either returns its non-empty `tenantId` or throws
* `ContactsApiError(UNAUTHORIZED)` mapped to HTTP 401. Routes call
* this once at handler entry so the rest of the handler can treat
* `tenantId` as definitely present.
*/
import type { Context } from "hono";
import {
CONTACTS_API_CODES,
ContactsApiError,
} from "../../error-codes/error-codes.js";
export interface TenantPrincipal {
readonly id: string;
readonly tenantId: string;
}
/**
* Return `{ id, tenantId }` from the request principal or throw
* `UNAUTHORIZED`. The shared helper eliminates the upstream port's
* repeated five-line `c.get("user")?.tenantId` checks and the
* `as any` casts they relied on.
*/
export function requireTenant(c: Context): TenantPrincipal {
const principal = c.get("user") as
| { id?: unknown; tenantId?: unknown }
| null
| undefined;
if (
principal === null ||
principal === undefined ||
typeof principal.tenantId !== "string" ||
principal.tenantId.length === 0
) {
throw new ContactsApiError(
CONTACTS_API_CODES.UNAUTHORIZED,
"request principal is missing a tenantId",
{ context: { statusCode: 401 } }
);
}
return {
id:
typeof principal.id === "string" && principal.id.length > 0
? principal.id
: "",
tenantId: principal.tenantId,
};
}

View File

@@ -0,0 +1,59 @@
# routes/shared/strip-undefined — drop undefined-valued keys
## Purpose
Bridges Zod's optional-field semantics
(`.optional() → string | undefined`) with the suaveplan
`@suaveplan/contacts` input shapes, which use
`exactOptionalPropertyTypes: true` and therefore forbid the
`| undefined` half of optional members. Returning a shallow copy with
`undefined` keys removed lets the route handlers feed Zod output
directly into `repo.create({ ...stripUndefined(parsed.data), tenantId })`
without losing type safety.
Without this helper every route handler would need to manually
re-build the input object key-by-key, or every Zod schema would have
to redefine its optionality via `.transform()`. The helper is the
focused fix.
## Features
- Shallow — preserves nested undefineds because the consumer Zod
schemas only produce top-level optionals.
- Preserves `null`, `0`, `""`, and `false` (only `undefined` is dropped).
- Pure / type-safe — returns `Partial<T>`-compatible shape.
## Usage: Basic
```ts
import { stripUndefined } from "@suaveplan/api-plugin-contacts";
const parsed = CreateContactSchema.parse(input);
const repoInput = stripUndefined(parsed);
await repo.create({ ...repoInput, tenantId });
```
## Usage: Advanced
```ts
import { stripUndefined } from "@suaveplan/api-plugin-contacts";
function safeMerge<T extends Record<string, unknown>>(base: T, patch: Partial<T>): T {
return { ...base, ...stripUndefined(patch) } as T;
}
```
## API Reference
- `stripUndefined<T>(input: T): { [K in keyof T]?: T[K] }` — shallow
copy with `undefined` keys omitted.
## Implementation Notes
- Uses `Object.entries` so symbol-keyed properties are ignored; route
inputs are always plain objects so this is fine.
- Does not deep-clone — the returned object shares nested references
with the input.
- The return type widens to `Partial<T>` for ergonomics; the runtime
contract is stricter (any non-`undefined` original value flows
through unchanged).

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { stripUndefined } from "./strip-undefined.js";
describe("stripUndefined", () => {
it("removes keys whose values are undefined", { timeout: 5000 }, () => {
const r = stripUndefined({ a: 1, b: undefined, c: "x" });
expect(r).toEqual({ a: 1, c: "x" });
expect("b" in r).toBe(false);
});
it("preserves null and falsy non-undefined values", {
timeout: 5000,
}, () => {
const r = stripUndefined({ a: null, b: 0, c: "", d: false });
expect(r).toEqual({ a: null, b: 0, c: "", d: false });
});
it("returns an empty object when every value is undefined", {
timeout: 5000,
}, () => {
expect(stripUndefined({ a: undefined, b: undefined })).toEqual({});
});
it("is shallow — nested undefineds are preserved", {
timeout: 5000,
}, () => {
const r = stripUndefined({ a: { b: undefined } });
expect(r).toEqual({ a: { b: undefined } });
});
});

View File

@@ -0,0 +1,22 @@
/**
* Strip keys whose values are `undefined`. Used at every route → repo
* boundary to bridge Zod's `optional()` semantics (which produce
* `string | undefined`) with the `exactOptionalPropertyTypes: true`
* Contact / Group / Membership input types in `@suaveplan/contacts`
* (which forbid the `| undefined` half).
*/
/**
* Return a shallow copy of the input with all keys whose values are
* exactly `undefined` removed. Non-`undefined` values are preserved
* as-is; `null` is preserved.
*/
export function stripUndefined<T extends Record<string, unknown>>(
input: T
): { [K in keyof T]: Exclude<T[K], undefined> } {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(input)) {
if (v !== undefined) out[k] = v;
}
return out as { [K in keyof T]: Exclude<T[K], undefined> };
}