Initial Commit
This commit is contained in:
86
packages/api-stack/api/api-tenant/src/context/context.md
Normal file
86
packages/api-stack/api/api-tenant/src/context/context.md
Normal 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.
|
||||
142
packages/api-stack/api/api-tenant/src/context/context.test.ts
Normal file
142
packages/api-stack/api/api-tenant/src/context/context.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
79
packages/api-stack/api/api-tenant/src/context/context.ts
Normal file
79
packages/api-stack/api/api-tenant/src/context/context.ts
Normal 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,
|
||||
});
|
||||
5
packages/api-stack/api/api-tenant/src/context/index.ts
Normal file
5
packages/api-stack/api/api-tenant/src/context/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type { TenantContext } from "./context.js";
|
||||
export {
|
||||
createTenantContext,
|
||||
NullTenantContext,
|
||||
} from "./context.js";
|
||||
Reference in New Issue
Block a user