Initial Commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
requireTenant,
|
||||
type TenantPrincipal,
|
||||
} from "./require-tenant.js";
|
||||
export { stripUndefined } from "./strip-undefined.js";
|
||||
@@ -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.
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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 } });
|
||||
});
|
||||
});
|
||||
@@ -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> };
|
||||
}
|
||||
Reference in New Issue
Block a user