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,86 @@
# context
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `context` module ships `createTenantContext(tenant)` — a pure
factory that wraps a resolved `Tenant` in an ergonomic helper bag
(`isPluginEnabled`, `getSetting`, `isFeatureEnabled`, `getLimit`) and
the `NullTenantContext` constant used when no tenant resolved. The
middleware does NOT store the helper bag on the Hono context;
`c.set("tenant", tenant)` is the single canonical key per the spec.
Callers needing the helpers construct a context on demand —
`createTenantContext(c.get("tenant"))` — which keeps the
"one-context-key" invariant intact.
`NullTenantContext` is the deliberate suaveplan behaviour flip: every
predicate returns `false` and `getSetting` returns the supplied
default. Upstream's permissive `() => true` allowed tenant-less
requests to silently inherit plugin permissions; the new default
fails closed so the only requests that pass `isPluginEnabled` are
those carrying a real, resolved tenant.
## Features
- `createTenantContext(tenant)` — pure factory, no side effects, no
shared mutable state. Safe to call per request.
- `isPluginEnabled(name)` reads `tenant.enabledPlugins`.
- `getSetting<T>(dotPath, default?)` walks dot-notation through
`tenant.settings`, returning `default` at any missing segment.
- `isFeatureEnabled(name)` reads `tenant.settings.features?.[name]`.
- `getLimit(key)` reads `tenant.settings.limits?.[key]` and returns
`undefined` if not set.
- `NullTenantContext` — frozen, fail-closed implementation.
`isPluginEnabled` always returns `false`; `isFeatureEnabled` always
returns `false`; `getLimit` always returns `undefined`;
`getSetting` always returns `defaultValue` (or `undefined`).
## Usage: Basic
```ts
import { createTenantContext, type Tenant } from "@suaveplan/api-tenant";
const tenant: Tenant = /* … */ undefined as never;
const ctx = createTenantContext(tenant);
if (ctx.isPluginEnabled("payment")) {
/* enable payment routes */
}
```
## Usage: Advanced
Reading deep settings with a typed default and pairing with the
fail-closed null context for tenant-less code paths:
```ts
import {
createTenantContext,
NullTenantContext,
type Tenant,
} from "@suaveplan/api-tenant";
function timezone(t: Tenant | null): string {
const ctx = t ? createTenantContext(t) : NullTenantContext;
return ctx.getSetting<string>("timezone", "UTC") ?? "UTC";
}
```
## API Reference
- `interface TenantContext` — `tenant`, `tenantId`, `isPluginEnabled`,
`getSetting`, `isFeatureEnabled`, `getLimit`.
- `function createTenantContext(tenant: Tenant): TenantContext`.
- `const NullTenantContext: TenantContext` — frozen fail-closed
singleton.
## Implementation Notes
`getSetting` short-circuits on `null`/`undefined` rather than relying
on the optional-chaining operator alone, so a settings tree that
explicitly stores `null` mid-path returns the default instead of
throwing. `NullTenantContext` is `Object.freeze`-d so consumers cannot
mutate the shared singleton — the constant is safe to pass around
without defensive copies.

View File

@@ -0,0 +1,142 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Tenant } from "../types/tenant.js";
import { createTenantContext, NullTenantContext } from "./context.js";
function makeTenant(over: Partial<Tenant> = {}): Tenant {
return {
id: "t_1",
name: "Acme",
slug: "acme",
status: "active",
settings: {},
enabledPlugins: ["payment"],
domains: [],
createdAt: new Date(0),
updatedAt: new Date(0),
...over,
};
}
describe("createTenantContext", () => {
it("exposes tenant and tenantId", { timeout: 5000 }, () => {
const ctx = createTenantContext(makeTenant());
expect(ctx.tenant.slug).toBe("acme");
expect(ctx.tenantId).toBe("t_1");
});
it("isPluginEnabled honours enabledPlugins", { timeout: 5000 }, () => {
const ctx = createTenantContext(makeTenant());
expect(ctx.isPluginEnabled("payment")).toBe(true);
expect(ctx.isPluginEnabled("ghost")).toBe(false);
});
it("getSetting walks dot-paths and returns default", {
timeout: 5000,
}, () => {
const ctx = createTenantContext(
makeTenant({
settings: {
custom: { nested: { value: 42 } },
timezone: "UTC",
},
})
);
expect(ctx.getSetting<number>("custom.nested.value")).toBe(42);
expect(ctx.getSetting<string>("timezone")).toBe("UTC");
expect(ctx.getSetting<number>("missing.path", 7)).toBe(7);
expect(ctx.getSetting<number>("missing.path")).toBeUndefined();
});
it("getSetting returns default when leaf is nullish", {
timeout: 5000,
}, () => {
const ctx = createTenantContext(
makeTenant({
settings: {
custom: {
explicitNull: null,
zero: 0,
} as Record<string, unknown>,
},
})
);
expect(ctx.getSetting<number>("custom.explicitNull", 42)).toBe(42);
// Leaf 0 is non-nullish; default ignored.
expect(ctx.getSetting<number>("custom.zero", 9)).toBe(0);
});
it("getSetting returns default when intermediate is null", {
timeout: 5000,
}, () => {
const ctx = createTenantContext(
makeTenant({
settings: {
custom: { nullSlot: null } as Record<string, unknown>,
},
})
);
expect(ctx.getSetting<number>("custom.nullSlot.x", 9)).toBe(9);
});
it("isFeatureEnabled reads features boolean", { timeout: 5000 }, () => {
const ctx = createTenantContext(
makeTenant({ settings: { features: { x: true, y: false } } })
);
expect(ctx.isFeatureEnabled("x")).toBe(true);
expect(ctx.isFeatureEnabled("y")).toBe(false);
expect(ctx.isFeatureEnabled("missing")).toBe(false);
});
it("isFeatureEnabled is false when no features map", {
timeout: 5000,
}, () => {
const ctx = createTenantContext(makeTenant());
expect(ctx.isFeatureEnabled("x")).toBe(false);
});
it("getLimit reads limits map", { timeout: 5000 }, () => {
const ctx = createTenantContext(
makeTenant({
settings: { limits: { maxUsers: 10, maxApiCalls: 1000 } },
})
);
expect(ctx.getLimit("maxUsers")).toBe(10);
expect(ctx.getLimit("maxApiCalls")).toBe(1000);
expect(ctx.getLimit("unknownKey")).toBeUndefined();
});
it("getLimit returns undefined when no limits", { timeout: 5000 }, () => {
const ctx = createTenantContext(makeTenant());
expect(ctx.getLimit("maxUsers")).toBeUndefined();
});
});
describe("NullTenantContext", () => {
it("NullTenantContext fail-closes plugin check", { timeout: 5000 }, () => {
expect(NullTenantContext.isPluginEnabled("payment")).toBe(false);
expect(NullTenantContext.isPluginEnabled("anything")).toBe(false);
});
it("NullTenantContext fail-closes feature check", {
timeout: 5000,
}, () => {
expect(NullTenantContext.isFeatureEnabled("x")).toBe(false);
});
it("getSetting returns supplied default", { timeout: 5000 }, () => {
expect(NullTenantContext.getSetting<number>("any.key", 7)).toBe(7);
expect(NullTenantContext.getSetting<number>("any.key")).toBeUndefined();
});
it("getLimit returns undefined", { timeout: 5000 }, () => {
expect(NullTenantContext.getLimit("maxUsers")).toBeUndefined();
});
it("tenantId is empty string", { timeout: 5000 }, () => {
expect(NullTenantContext.tenantId).toBe("");
});
it("is frozen", { timeout: 5000 }, () => {
expect(Object.isFrozen(NullTenantContext)).toBe(true);
});
});

View File

@@ -0,0 +1,79 @@
import type { Tenant } from "../types/tenant.js";
/**
* Per-request helper bag derived from a resolved `Tenant`. The middleware
* does NOT store this on the Hono context — `c.set("tenant", tenant)` is
* the single canonical key. Callers can construct a context on demand
* with `createTenantContext(c.get("tenant"))` when they want the helper
* accessors.
*/
export interface TenantContext {
/** The underlying tenant record. */
readonly tenant: Tenant;
/** Shorthand for `tenant.id`. */
readonly tenantId: string;
/** Returns `true` iff `tenant.enabledPlugins` contains `pluginName`. */
isPluginEnabled(pluginName: string): boolean;
/**
* Read a dot-path from `tenant.settings`. Returns `defaultValue` (or
* `undefined`) when any segment is missing.
*/
getSetting<T>(key: string, defaultValue?: T): T | undefined;
/** Returns `true` iff `tenant.settings.features?.[name]` is `true`. */
isFeatureEnabled(feature: string): boolean;
/** Read a single `TenantLimits` field. */
getLimit(key: string): number | undefined;
}
/** Build a `TenantContext` helper bag from a resolved tenant. */
export function createTenantContext(tenant: Tenant): TenantContext {
return {
tenant,
tenantId: tenant.id,
isPluginEnabled(pluginName: string): boolean {
return tenant.enabledPlugins.includes(pluginName);
},
getSetting<T>(key: string, defaultValue?: T): T | undefined {
const keys = key.split(".");
let value: unknown = tenant.settings;
for (const k of keys) {
if (value === null || value === undefined) return defaultValue;
value = (value as Record<string, unknown>)[k];
}
return (value as T) ?? defaultValue;
},
isFeatureEnabled(feature: string): boolean {
return tenant.settings.features?.[feature] ?? false;
},
getLimit(key: string): number | undefined {
const limits = tenant.settings.limits;
if (!limits) return undefined;
return limits[key as keyof typeof limits];
},
};
}
/**
* Fail-closed null context — every check returns `false`/`undefined`.
* Genesis deliberately flips the upstream default (which returned
* `true`) so a tenant-less request cannot silently inherit permissions.
*/
export const NullTenantContext: TenantContext = Object.freeze({
tenant: Object.freeze({
id: "",
name: "Null",
slug: "",
status: "deleted" as const,
settings: {},
enabledPlugins: [] as string[],
domains: [] as string[],
createdAt: new Date(0),
updatedAt: new Date(0),
}),
tenantId: "",
isPluginEnabled: () => false,
getSetting: <T>(_key: string, defaultValue?: T): T | undefined =>
defaultValue,
isFeatureEnabled: () => false,
getLimit: () => undefined,
});

View File

@@ -0,0 +1,5 @@
export type { TenantContext } from "./context.js";
export {
createTenantContext,
NullTenantContext,
} from "./context.js";