@inbeat/next 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,90 @@
1
+ # Changelog
2
+
3
+ ## [0.7.0](https://github.com/Genoux/ib-toolkit/compare/next-v0.6.1...next-v0.7.0) (2026-10-01)
4
+
5
+
6
+ ### ⚠ BREAKING CHANGES
7
+
8
+ * consumers must drop the @inbeat registry line from .npmrc/bunfig and the NODE_AUTH_TOKEN setup.
9
+
10
+ ### Features
11
+
12
+ * publish [@inbeat](https://github.com/inbeat) packages publicly on npm ([#27](https://github.com/Genoux/ib-toolkit/issues/27)) ([2bd9f53](https://github.com/Genoux/ib-toolkit/commit/2bd9f53c2d4dd030bdee037b8b1dce1e1bb828b5))
13
+
14
+ ## [0.6.1](https://github.com/Genoux/ib-toolkit/compare/next-v0.6.0...next-v0.6.1) (2026-10-01)
15
+
16
+
17
+ ### Miscellaneous Chores
18
+
19
+ * **next:** Synchronize inbeat versions
20
+
21
+ ## [0.6.0](https://github.com/Genoux/ib-toolkit/compare/next-v0.5.0...next-v0.6.0) (2026-10-01)
22
+
23
+
24
+ ### ⚠ BREAKING CHANGES
25
+
26
+ * remove app logic and finish the toolkit ([#21](https://github.com/Genoux/ib-toolkit/issues/21))
27
+
28
+ ### Code Refactoring
29
+
30
+ * remove app logic and finish the toolkit ([#21](https://github.com/Genoux/ib-toolkit/issues/21)) ([cecf25a](https://github.com/Genoux/ib-toolkit/commit/cecf25a670b2177f8b1e8cc3645023a1219a2626))
31
+
32
+ ## [0.5.0](https://github.com/Genoux/ib-toolkit/compare/next-v0.4.0...next-v0.5.0) (2026-10-01)
33
+
34
+
35
+ ### ⚠ BREAKING CHANGES
36
+
37
+ * **standards:** `ib add` is removed (use the vendor init CLIs); `ib check` now requires the shared config presets.
38
+
39
+ ### Features
40
+
41
+ * **standards:** drop add-ons, add ib mcp, enforce shared presets ([#17](https://github.com/Genoux/ib-toolkit/issues/17)) ([9eb8410](https://github.com/Genoux/ib-toolkit/commit/9eb84108432dda5e2a2da312a7bc70299e09c0c9))
42
+
43
+ ## [0.4.0](https://github.com/Genoux/ib-toolkit/compare/next-v0.3.0...next-v0.4.0) (2026-09-30)
44
+
45
+
46
+ ### ⚠ BREAKING CHANGES
47
+
48
+ * **standards:** sync skills to .agents/skills only ([#15](https://github.com/Genoux/ib-toolkit/issues/15))
49
+
50
+ ### Features
51
+
52
+ * **standards:** sync skills to .agents/skills only ([#15](https://github.com/Genoux/ib-toolkit/issues/15)) ([cd3e8a2](https://github.com/Genoux/ib-toolkit/commit/cd3e8a21f028790ad84c26dc1cc832475885c461))
53
+
54
+ ## [0.3.0](https://github.com/Genoux/ib-toolkit/compare/next-v0.2.0...next-v0.3.0) (2026-09-30)
55
+
56
+
57
+ ### Miscellaneous Chores
58
+
59
+ * **next:** Synchronize inbeat versions
60
+
61
+ ## [0.2.0](https://github.com/Genoux/ib-toolkit/compare/next-v0.1.2...next-v0.2.0) (2026-09-30)
62
+
63
+
64
+ ### ⚠ BREAKING CHANGES
65
+
66
+ * **ui,next:** harden ui peers, csp and rate-limit ip ([#8](https://github.com/Genoux/ib-toolkit/issues/8))
67
+ * make the template a standalone project starter ([#7](https://github.com/Genoux/ib-toolkit/issues/7))
68
+
69
+ ### Features
70
+
71
+ * make the template a standalone project starter ([#7](https://github.com/Genoux/ib-toolkit/issues/7)) ([98656a0](https://github.com/Genoux/ib-toolkit/commit/98656a0e22e90594f1f10d585aad61ed7de255ea))
72
+
73
+
74
+ ### Bug Fixes
75
+
76
+ * **ui,next:** harden ui peers, csp and rate-limit ip ([#8](https://github.com/Genoux/ib-toolkit/issues/8)) ([4ad992c](https://github.com/Genoux/ib-toolkit/commit/4ad992c96181a21c3dbee0bb542d9afe32163deb))
77
+
78
+ ## [0.1.2](https://github.com/Genoux/ib-toolkit/compare/next-v0.1.1...next-v0.1.2) (2026-09-30)
79
+
80
+
81
+ ### Bug Fixes
82
+
83
+ * resolve workspace ranges from manifests on publish ([#5](https://github.com/Genoux/ib-toolkit/issues/5)) ([0f4328e](https://github.com/Genoux/ib-toolkit/commit/0f4328eb0897501380880f429a952d9d228563a2))
84
+
85
+ ## [0.1.1](https://github.com/Genoux/ib-toolkit/compare/next-v0.1.0...next-v0.1.1) (2026-09-30)
86
+
87
+
88
+ ### Bug Fixes
89
+
90
+ * ship compiled js from core and next ([#3](https://github.com/Genoux/ib-toolkit/issues/3)) ([8fef50a](https://github.com/Genoux/ib-toolkit/commit/8fef50aa87679bcf3c328fe4ca11a351434e2dda))
@@ -0,0 +1,21 @@
1
+ import { type Result } from "@inbeat/core/result";
2
+ import type { z } from "zod";
3
+ export type ActionConfig<TSchema extends z.ZodType, TViewer, TData> = {
4
+ name: string;
5
+ schema: TSchema;
6
+ /**
7
+ * Required on purpose: an action cannot exist without an explicit access decision.
8
+ * Throw `AppError("auth" | "forbidden")` to deny; use `publicAccess` for unauthenticated
9
+ * actions, which must then be rate limited inside `run`.
10
+ */
11
+ authorize: () => Promise<TViewer>;
12
+ run: (input: z.output<TSchema>, viewer: TViewer) => Promise<Result<TData>>;
13
+ revalidate?: (input: z.output<TSchema>, data: TData) => string[];
14
+ };
15
+ export declare const publicAccess: () => Promise<null>;
16
+ /**
17
+ * Server action contract: authorize, validate, run, revalidate, and always resolve to a Result.
18
+ * Expected failures come back as values; anything thrown is classified, reported and replaced
19
+ * with safe copy, so no raw error message ever crosses to the client.
20
+ */
21
+ export declare function action<TSchema extends z.ZodType, TViewer, TData>(config: ActionConfig<TSchema, TViewer, TData>): (input: z.input<TSchema>) => Promise<Result<TData>>;
package/dist/action.js ADDED
@@ -0,0 +1,29 @@
1
+ import { fail } from "@inbeat/core/result";
2
+ import { revalidatePath } from "next/cache.js";
3
+ import { unstable_rethrow } from "next/navigation.js";
4
+ import { captureAppError } from "./capture.js";
5
+ export const publicAccess = async () => null;
6
+ /**
7
+ * Server action contract: authorize, validate, run, revalidate, and always resolve to a Result.
8
+ * Expected failures come back as values; anything thrown is classified, reported and replaced
9
+ * with safe copy, so no raw error message ever crosses to the client.
10
+ */
11
+ export function action(config) {
12
+ return async (rawInput) => {
13
+ try {
14
+ const viewer = await config.authorize();
15
+ const input = config.schema.parse(rawInput);
16
+ const result = await config.run(input, viewer);
17
+ if (result.ok) {
18
+ for (const path of config.revalidate?.(input, result.data) ?? [])
19
+ revalidatePath(path);
20
+ }
21
+ return result;
22
+ }
23
+ catch (err) {
24
+ unstable_rethrow(err);
25
+ const captured = captureAppError(err, { tags: { action: config.name } });
26
+ return fail(captured.safeMessage, captured.kind, captured.errorId);
27
+ }
28
+ };
29
+ }
@@ -0,0 +1,8 @@
1
+ import { type ClassifiedError } from "@inbeat/core/errors";
2
+ export type CapturedError = ClassifiedError & {
3
+ errorId?: string;
4
+ };
5
+ export declare function captureAppError(err: unknown, context?: {
6
+ tags?: Record<string, string>;
7
+ extra?: Record<string, unknown>;
8
+ }): CapturedError;
@@ -0,0 +1,25 @@
1
+ import { classifyError } from "@inbeat/core/errors";
2
+ import { createLogger } from "@inbeat/core/logger";
3
+ import * as Sentry from "@sentry/nextjs";
4
+ const logger = createLogger();
5
+ /** Short id shown to users so support can find the Sentry event or the log line. */
6
+ function toErrorId(eventId) {
7
+ return eventId ? `ERR_${eventId.slice(0, 8).toUpperCase()}` : undefined;
8
+ }
9
+ // Logged as well as reported: an app without a Sentry DSN would otherwise lose these errors.
10
+ export function captureAppError(err, context = {}) {
11
+ const classified = classifyError(err);
12
+ if (!classified.reportToSentry)
13
+ return classified;
14
+ const exception = classified.original instanceof Error
15
+ ? classified.original
16
+ : new Error(String(classified.original));
17
+ const eventId = Sentry.captureException(exception, {
18
+ tags: { ...classified.tags, ...context.tags },
19
+ extra: context.extra,
20
+ fingerprint: classified.fingerprint,
21
+ });
22
+ const errorId = toErrorId(eventId);
23
+ logger.error("Unexpected error", exception, { errorId, ...context.tags });
24
+ return { ...classified, errorId };
25
+ }
@@ -0,0 +1,18 @@
1
+ import "server-only";
2
+ import { type WebhookEvent } from "@clerk/nextjs/webhooks";
3
+ type EventOf<TType extends WebhookEvent["type"]> = WebhookEvent extends infer TEvent ? TEvent extends {
4
+ type: infer TUnion;
5
+ } ? TType extends TUnion ? TEvent & {
6
+ type: TType;
7
+ } : never : never : never;
8
+ export type ClerkWebhookHandlers = {
9
+ [TType in WebhookEvent["type"]]?: (event: EventOf<TType>) => Promise<void>;
10
+ };
11
+ /**
12
+ * Returns 400 only when the signature is invalid. Processing failures return 500 so Svix
13
+ * retries them instead of silently dropping the event.
14
+ */
15
+ export declare function createClerkWebhookHandler(handlers: ClerkWebhookHandlers, options?: {
16
+ signingSecret?: string;
17
+ }): (request: Request) => Promise<Response>;
18
+ export {};
@@ -0,0 +1,29 @@
1
+ import "server-only";
2
+ import { verifyWebhook } from "@clerk/nextjs/webhooks";
3
+ import { captureAppError } from "./capture.js";
4
+ /**
5
+ * Returns 400 only when the signature is invalid. Processing failures return 500 so Svix
6
+ * retries them instead of silently dropping the event.
7
+ */
8
+ export function createClerkWebhookHandler(handlers, options = {}) {
9
+ return async function POST(request) {
10
+ let event;
11
+ try {
12
+ event = await verifyWebhook(request, options);
13
+ }
14
+ catch {
15
+ return new Response("Invalid signature", { status: 400 });
16
+ }
17
+ const handler = handlers[event.type];
18
+ if (!handler)
19
+ return new Response(null, { status: 204 });
20
+ try {
21
+ await handler(event);
22
+ return new Response(null, { status: 204 });
23
+ }
24
+ catch (err) {
25
+ captureAppError(err, { tags: { webhook: `clerk.${event.type}` } });
26
+ return new Response("Processing failed", { status: 500 });
27
+ }
28
+ };
29
+ }
package/dist/db.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ import "server-only";
2
+ import { neonConfig, Pool, type PoolConfig } from "@neondatabase/serverless";
3
+ import { type NeonDatabase } from "drizzle-orm/neon-serverless";
4
+ export type CreateDbOptions<TSchema extends Record<string, unknown>> = {
5
+ url: string;
6
+ schema: TSchema;
7
+ /** Stable key so dev hot reloads reuse one pool instead of leaking connections. */
8
+ globalKey: string;
9
+ pool?: Omit<PoolConfig, "connectionString">;
10
+ webSocketConstructor?: typeof neonConfig.webSocketConstructor;
11
+ production?: boolean;
12
+ };
13
+ export declare function createDb<TSchema extends Record<string, unknown>>({ url, schema, globalKey, pool: poolOptions, webSocketConstructor, production, }: CreateDbOptions<TSchema>): {
14
+ db: NeonDatabase<TSchema>;
15
+ pool: Pool;
16
+ };
17
+ export type Db<TSchema extends Record<string, unknown>> = NeonDatabase<TSchema>;
18
+ export type DbTransaction<TSchema extends Record<string, unknown>> = Parameters<Parameters<NeonDatabase<TSchema>["transaction"]>[0]>[0];
package/dist/db.js ADDED
@@ -0,0 +1,17 @@
1
+ import "server-only";
2
+ import { neonConfig, Pool } from "@neondatabase/serverless";
3
+ import { drizzle } from "drizzle-orm/neon-serverless";
4
+ export function createDb({ url, schema, globalKey, pool: poolOptions, webSocketConstructor,
5
+ // biome-ignore lint/style/noProcessEnv: NODE_ENV is inlined by the bundler, not app config
6
+ production = process.env.NODE_ENV === "production", }) {
7
+ if (webSocketConstructor)
8
+ neonConfig.webSocketConstructor = webSocketConstructor;
9
+ // Sends the password with the startup message, saving a round trip per new connection.
10
+ neonConfig.pipelineConnect = "password";
11
+ const store = globalThis;
12
+ const pool = (!production && store[globalKey]) ||
13
+ new Pool({ connectionString: url, max: 10, idleTimeoutMillis: 30_000, ...poolOptions });
14
+ if (!production)
15
+ store[globalKey] = pool;
16
+ return { db: drizzle(pool, { schema }), pool };
17
+ }
@@ -0,0 +1 @@
1
+ export declare const fontVariables: string;
package/dist/fonts.js ADDED
@@ -0,0 +1,4 @@
1
+ import { GeistMono } from "geist/font/mono";
2
+ import { GeistSans } from "geist/font/sans";
3
+ // Goes on <html>: theme.css resolves --font-sans there, so variables set on <body> fall back to the system font.
4
+ export const fontVariables = `${GeistSans.variable} ${GeistMono.variable}`;
@@ -0,0 +1,5 @@
1
+ export type MaintenanceConfig = {
2
+ enabled: boolean;
3
+ bypassEmails: readonly string[];
4
+ };
5
+ export declare function isMaintenanceBlocking(config: MaintenanceConfig, email: string | null): boolean;
@@ -0,0 +1,9 @@
1
+ import { normalizeEmail } from "@inbeat/core/email";
2
+ export function isMaintenanceBlocking(config, email) {
3
+ if (!config.enabled)
4
+ return false;
5
+ if (!email)
6
+ return true;
7
+ const normalized = normalizeEmail(email);
8
+ return !config.bypassEmails.some((bypass) => normalizeEmail(bypass) === normalized);
9
+ }
@@ -0,0 +1,38 @@
1
+ import { type Duration } from "@upstash/ratelimit";
2
+ export type RateLimitPolicy = {
3
+ limit: number;
4
+ window: Duration;
5
+ };
6
+ export type RateLimitOutcome = {
7
+ success: boolean;
8
+ remaining: number;
9
+ reset: number;
10
+ };
11
+ export type RateLimiter = {
12
+ check: (key: string, policy: RateLimitPolicy) => Promise<RateLimitOutcome>;
13
+ /** Throws `AppError("rate_limited")` so actions and routes answer 429 with safe copy. */
14
+ enforce: (key: string, policy: RateLimitPolicy) => Promise<void>;
15
+ backend: "upstash" | "memory";
16
+ };
17
+ export type RateLimiterOptions = {
18
+ prefix: string;
19
+ upstash?: {
20
+ url?: string;
21
+ token?: string;
22
+ };
23
+ onMemoryFallback?: () => void;
24
+ };
25
+ export declare function createRateLimiter({ prefix, upstash, onMemoryFallback, }: RateLimiterOptions): RateLimiter;
26
+ export type ClientIpOptions = {
27
+ /** Proxies you operate in front of the app; takes that many entries from the right of `x-forwarded-for`. */
28
+ trustedProxyHops?: number;
29
+ env?: Record<string, string | undefined>;
30
+ };
31
+ /**
32
+ * Client IP for rate-limit keys, `"unknown"` when it cannot be trusted. Proxy headers are trusted
33
+ * only on Vercel, which sets them itself (https://vercel.com/docs/headers/request-headers);
34
+ * anywhere else they are client-controlled, so they are ignored unless `trustedProxyHops` says
35
+ * how many proxies of yours sit in front. IPv6 collapses to its /64 so a client cannot rotate
36
+ * addresses inside its own prefix.
37
+ */
38
+ export declare function clientIp(headers: Pick<Headers, "get">, { trustedProxyHops, env, }?: ClientIpOptions): string;
@@ -0,0 +1,109 @@
1
+ import { isIP } from "node:net";
2
+ import { AppError } from "@inbeat/core/errors";
3
+ import { Ratelimit } from "@upstash/ratelimit";
4
+ import { Redis } from "@upstash/redis";
5
+ const UNIT_MS = { ms: 1, s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 };
6
+ function durationMs(window) {
7
+ const [amount, unit] = window.split(" ");
8
+ return Number(amount) * (UNIT_MS[unit] ?? 1_000);
9
+ }
10
+ /**
11
+ * Per-instance fixed window. Only correct on a single process (local dev, tests):
12
+ * on Vercel every instance keeps its own counters.
13
+ */
14
+ function memoryCheck() {
15
+ const buckets = new Map();
16
+ return async (key, policy) => {
17
+ const now = Date.now();
18
+ if (buckets.size > 10_000) {
19
+ for (const [k, b] of buckets)
20
+ if (b.resetAt <= now)
21
+ buckets.delete(k);
22
+ }
23
+ const bucket = buckets.get(key);
24
+ const active = bucket && bucket.resetAt > now
25
+ ? bucket
26
+ : { count: 0, resetAt: now + durationMs(policy.window) };
27
+ active.count++;
28
+ buckets.set(key, active);
29
+ return {
30
+ success: active.count <= policy.limit,
31
+ remaining: Math.max(0, policy.limit - active.count),
32
+ reset: active.resetAt,
33
+ };
34
+ };
35
+ }
36
+ function upstashCheck(redis, prefix) {
37
+ const limiters = new Map();
38
+ return async (key, policy) => {
39
+ const id = `${policy.limit}:${policy.window}`;
40
+ let limiter = limiters.get(id);
41
+ if (!limiter) {
42
+ limiter = new Ratelimit({
43
+ redis,
44
+ prefix: `${prefix}:${id}`,
45
+ limiter: Ratelimit.slidingWindow(policy.limit, policy.window),
46
+ });
47
+ limiters.set(id, limiter);
48
+ }
49
+ const { success, remaining, reset } = await limiter.limit(key);
50
+ return { success, remaining, reset };
51
+ };
52
+ }
53
+ export function createRateLimiter({ prefix, upstash, onMemoryFallback, }) {
54
+ const hasUpstash = Boolean(upstash?.url && upstash?.token);
55
+ if (!hasUpstash)
56
+ onMemoryFallback?.();
57
+ const check = hasUpstash
58
+ ? upstashCheck(new Redis({ url: upstash?.url, token: upstash?.token }), prefix)
59
+ : memoryCheck();
60
+ return {
61
+ backend: hasUpstash ? "upstash" : "memory",
62
+ check,
63
+ enforce: async (key, policy) => {
64
+ const outcome = await check(key, policy);
65
+ if (!outcome.success) {
66
+ throw new AppError("rate_limited", `Rate limit hit for ${key.split(":")[0]}`);
67
+ }
68
+ },
69
+ };
70
+ }
71
+ function normalizeIp(candidate) {
72
+ const ip = candidate?.trim().split("%")[0];
73
+ if (!ip)
74
+ return null;
75
+ const kind = isIP(ip);
76
+ if (kind === 4)
77
+ return ip;
78
+ if (kind !== 6)
79
+ return null;
80
+ const mapped = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i)?.[1];
81
+ if (mapped)
82
+ return mapped;
83
+ const [head = "", tail = ""] = ip.split("::");
84
+ const headGroups = head ? head.split(":") : [];
85
+ const tailGroups = tail ? tail.split(":") : [];
86
+ const zeros = ip.includes("::") ? Array(8 - headGroups.length - tailGroups.length).fill("0") : [];
87
+ const prefix = [...headGroups, ...zeros, ...tailGroups]
88
+ .slice(0, 4)
89
+ .map((group) => Number.parseInt(group, 16).toString(16));
90
+ return `${prefix.join(":")}::/64`;
91
+ }
92
+ /**
93
+ * Client IP for rate-limit keys, `"unknown"` when it cannot be trusted. Proxy headers are trusted
94
+ * only on Vercel, which sets them itself (https://vercel.com/docs/headers/request-headers);
95
+ * anywhere else they are client-controlled, so they are ignored unless `trustedProxyHops` says
96
+ * how many proxies of yours sit in front. IPv6 collapses to its /64 so a client cannot rotate
97
+ * addresses inside its own prefix.
98
+ */
99
+ export function clientIp(headers, { trustedProxyHops,
100
+ // biome-ignore lint/style/noProcessEnv: VERCEL is platform-provided and read at call time
101
+ env = process.env, } = {}) {
102
+ const forwardedFor = headers.get("x-forwarded-for")?.split(",") ?? [];
103
+ const candidate = env.VERCEL
104
+ ? (headers.get("x-vercel-forwarded-for")?.split(",")[0] ?? forwardedFor[0])
105
+ : trustedProxyHops
106
+ ? forwardedFor[forwardedFor.length - trustedProxyHops]
107
+ : undefined;
108
+ return normalizeIp(candidate) ?? "unknown";
109
+ }
@@ -0,0 +1,29 @@
1
+ import type { z } from "zod";
2
+ export type RouteErrorBody = {
3
+ error: string;
4
+ code: string;
5
+ errorId?: string;
6
+ };
7
+ type RouteContext<TParams> = {
8
+ params: Promise<TParams>;
9
+ };
10
+ export type RouteConfig<TViewer, TParams extends Record<string, string | string[]>, TQuery extends z.ZodType | undefined, TBody extends z.ZodType | undefined> = {
11
+ name: string;
12
+ /** Same rule as `action()`: every handler states its access decision. */
13
+ authorize: (request: Request) => Promise<TViewer>;
14
+ query?: TQuery;
15
+ body?: TBody;
16
+ handler: (args: {
17
+ request: Request;
18
+ viewer: TViewer;
19
+ params: TParams;
20
+ query: TQuery extends z.ZodType ? z.output<TQuery> : undefined;
21
+ body: TBody extends z.ZodType ? z.output<TBody> : undefined;
22
+ }) => Promise<Response | unknown>;
23
+ };
24
+ /**
25
+ * Route handler contract. Returns JSON with a real HTTP status, and errors as
26
+ * `{ error, code, errorId? }` so every client can branch on `code`.
27
+ */
28
+ export declare function route<TViewer, TParams extends Record<string, string | string[]> = Record<string, never>, TQuery extends z.ZodType | undefined = undefined, TBody extends z.ZodType | undefined = undefined>(config: RouteConfig<TViewer, TParams, TQuery, TBody>): (request: Request, context: RouteContext<TParams>) => Promise<Response>;
29
+ export {};
package/dist/route.js ADDED
@@ -0,0 +1,48 @@
1
+ import { APP_ERROR_STATUS, AppError } from "@inbeat/core/errors";
2
+ import { NextResponse } from "next/server.js";
3
+ import { captureAppError } from "./capture.js";
4
+ function errorResponse(err, name) {
5
+ const captured = captureAppError(err, { tags: { route: name } });
6
+ return NextResponse.json({
7
+ error: captured.safeMessage,
8
+ code: captured.kind,
9
+ ...(captured.errorId ? { errorId: captured.errorId } : {}),
10
+ }, { status: APP_ERROR_STATUS[captured.kind] });
11
+ }
12
+ async function readJson(request) {
13
+ try {
14
+ return await request.json();
15
+ }
16
+ catch {
17
+ throw new AppError("validation", "Request body is not valid JSON", {
18
+ safeMessage: "The request body is not valid JSON.",
19
+ });
20
+ }
21
+ }
22
+ /**
23
+ * Route handler contract. Returns JSON with a real HTTP status, and errors as
24
+ * `{ error, code, errorId? }` so every client can branch on `code`.
25
+ */
26
+ export function route(config) {
27
+ return async (request, context) => {
28
+ try {
29
+ const viewer = await config.authorize(request);
30
+ const params = (await context?.params) ?? {};
31
+ const query = config.query
32
+ ? config.query.parse(Object.fromEntries(new URL(request.url).searchParams))
33
+ : undefined;
34
+ const body = config.body ? config.body.parse(await readJson(request)) : undefined;
35
+ const output = await config.handler({
36
+ request,
37
+ viewer,
38
+ params,
39
+ query: query,
40
+ body: body,
41
+ });
42
+ return output instanceof Response ? output : NextResponse.json(output ?? null);
43
+ }
44
+ catch (err) {
45
+ return errorResponse(err, config.name);
46
+ }
47
+ };
48
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Accepts only same-origin relative paths. Rejects protocol-relative (`//evil.com`),
3
+ * backslash tricks (`/\evil.com`) and absolute URLs so `?redirect_url=` can't bounce users out.
4
+ */
5
+ export declare function safeRedirectPath(candidate: string | null | undefined, fallback: string): string;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Accepts only same-origin relative paths. Rejects protocol-relative (`//evil.com`),
3
+ * backslash tricks (`/\evil.com`) and absolute URLs so `?redirect_url=` can't bounce users out.
4
+ */
5
+ export function safeRedirectPath(candidate, fallback) {
6
+ if (!candidate?.startsWith("/") || candidate.startsWith("//"))
7
+ return fallback;
8
+ if (candidate.includes("\\"))
9
+ return fallback;
10
+ try {
11
+ const url = new URL(candidate, "https://placeholder.invalid");
12
+ return url.origin === "https://placeholder.invalid"
13
+ ? `${url.pathname}${url.search}${url.hash}`
14
+ : fallback;
15
+ }
16
+ catch {
17
+ return fallback;
18
+ }
19
+ }
@@ -0,0 +1,31 @@
1
+ export type CspSources = {
2
+ script?: string[];
3
+ style?: string[];
4
+ img?: string[];
5
+ font?: string[];
6
+ connect?: string[];
7
+ media?: string[];
8
+ frame?: string[];
9
+ worker?: string[];
10
+ };
11
+ export type SecurityHeadersOptions = {
12
+ /** Frontend API host, see `clerkFrontendApi()`. */
13
+ clerkFrontendApi?: string;
14
+ sources?: CspSources;
15
+ /** Ship CSP as report-only first; enforce once a week of traffic shows no violations. */
16
+ cspReportOnly?: boolean;
17
+ cspReportUri?: string;
18
+ dev?: boolean;
19
+ };
20
+ export type NextHeader = {
21
+ key: string;
22
+ value: string;
23
+ };
24
+ /** Decodes the host baked into a Clerk publishable key (`pk_<env>_<base64(host$)>`). */
25
+ export declare function clerkFrontendApi(publishableKey: string | undefined): string | undefined;
26
+ export declare function contentSecurityPolicy({ clerkFrontendApi: clerk, sources, cspReportUri, dev, }?: SecurityHeadersOptions): string;
27
+ /**
28
+ * Use from `next.config.ts`: `headers: async () => [{ source: "/(.*)", headers: securityHeaders(…) }]`.
29
+ * X-Frame-Options stays even with CSP because browsers ignore `frame-ancestors` in report-only mode.
30
+ */
31
+ export declare function securityHeaders(options?: SecurityHeadersOptions): NextHeader[];
@@ -0,0 +1,75 @@
1
+ /** Decodes the host baked into a Clerk publishable key (`pk_<env>_<base64(host$)>`). */
2
+ export function clerkFrontendApi(publishableKey) {
3
+ const encoded = publishableKey?.split("_")[2];
4
+ if (!encoded)
5
+ return undefined;
6
+ try {
7
+ return atob(encoded).replace(/\$$/, "") || undefined;
8
+ }
9
+ catch {
10
+ return undefined;
11
+ }
12
+ }
13
+ function directive(name, values) {
14
+ return [name, ...new Set(values.filter(Boolean))].join(" ");
15
+ }
16
+ export function contentSecurityPolicy({ clerkFrontendApi: clerk, sources = {}, cspReportUri, dev = false, } = {}) {
17
+ const clerkOrigin = clerk && `https://${clerk}`;
18
+ const clerkImages = clerk && "https://img.clerk.com";
19
+ const turnstile = clerk && "https://challenges.cloudflare.com";
20
+ return [
21
+ directive("default-src", ["'self'"]),
22
+ directive("script-src", [
23
+ "'self'",
24
+ // Next injects inline bootstrap scripts; nonces would force every page dynamic.
25
+ "'unsafe-inline'",
26
+ dev && "'unsafe-eval'",
27
+ clerkOrigin,
28
+ turnstile,
29
+ ...(sources.script ?? []),
30
+ ]),
31
+ directive("style-src", ["'self'", "'unsafe-inline'", ...(sources.style ?? [])]),
32
+ directive("img-src", ["'self'", "data:", "blob:", clerkImages, ...(sources.img ?? [])]),
33
+ directive("font-src", ["'self'", "data:", ...(sources.font ?? [])]),
34
+ directive("connect-src", ["'self'", clerkOrigin, dev && "ws:", ...(sources.connect ?? [])]),
35
+ directive("media-src", ["'self'", "blob:", ...(sources.media ?? [])]),
36
+ directive("frame-src", ["'self'", turnstile, ...(sources.frame ?? [])]),
37
+ directive("worker-src", ["'self'", "blob:", ...(sources.worker ?? [])]),
38
+ directive("object-src", ["'none'"]),
39
+ directive("base-uri", ["'self'"]),
40
+ directive("form-action", ["'self'"]),
41
+ directive("frame-ancestors", ["'none'"]),
42
+ cspReportUri ? directive("report-uri", [cspReportUri]) : undefined,
43
+ ]
44
+ .filter(Boolean)
45
+ .join("; ");
46
+ }
47
+ /**
48
+ * Use from `next.config.ts`: `headers: async () => [{ source: "/(.*)", headers: securityHeaders(…) }]`.
49
+ * X-Frame-Options stays even with CSP because browsers ignore `frame-ancestors` in report-only mode.
50
+ */
51
+ export function securityHeaders(options = {}) {
52
+ const { cspReportOnly = true, dev = false } = options;
53
+ return [
54
+ {
55
+ key: cspReportOnly ? "Content-Security-Policy-Report-Only" : "Content-Security-Policy",
56
+ value: contentSecurityPolicy(options),
57
+ },
58
+ ...(dev
59
+ ? []
60
+ : [
61
+ {
62
+ key: "Strict-Transport-Security",
63
+ value: "max-age=63072000; includeSubDomains; preload",
64
+ },
65
+ ]),
66
+ { key: "X-Frame-Options", value: "DENY" },
67
+ { key: "X-Content-Type-Options", value: "nosniff" },
68
+ { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
69
+ {
70
+ key: "Permissions-Policy",
71
+ value: "camera=(), microphone=(), geolocation=(), payment=(), usb=(), browsing-topics=()",
72
+ },
73
+ { key: "Cross-Origin-Opener-Policy", value: "same-origin-allow-popups" },
74
+ ];
75
+ }
@@ -0,0 +1,4 @@
1
+ import { type SentryInitOptions, type SentryPresetOptions } from "./sentry.js";
2
+ export declare function sentryClientOptions(options: SentryPresetOptions & {
3
+ replaysOnErrorSampleRate?: number;
4
+ }): SentryInitOptions;
@@ -0,0 +1,17 @@
1
+ import * as Sentry from "@sentry/nextjs";
2
+ import { sentryServerOptions } from "./sentry.js";
3
+ export function sentryClientOptions(options) {
4
+ return {
5
+ ...sentryServerOptions(options),
6
+ integrations: [
7
+ Sentry.replayIntegration({
8
+ maskAllText: true,
9
+ maskAllInputs: true,
10
+ blockAllMedia: true,
11
+ unmask: ["[data-sentry-unmask]"],
12
+ }),
13
+ ],
14
+ replaysSessionSampleRate: 0,
15
+ replaysOnErrorSampleRate: options.replaysOnErrorSampleRate ?? 1,
16
+ };
17
+ }
@@ -0,0 +1,26 @@
1
+ import type * as Sentry from "@sentry/nextjs";
2
+ type ErrorEvent = Sentry.ErrorEvent;
3
+ type EventHint = Sentry.EventHint;
4
+ export type SentryInitOptions = NonNullable<Parameters<typeof Sentry.init>[0]>;
5
+ export type SentryPresetOptions = {
6
+ dsn: string | undefined;
7
+ enabled: boolean;
8
+ environment?: string;
9
+ tracesSampleRate?: number;
10
+ /** Extra drop rule, e.g. errors the app already turned into user-facing copy. */
11
+ ignore?: (hint: EventHint) => boolean;
12
+ };
13
+ /**
14
+ * Server and edge options. Browser options live in `@inbeat/next/sentry-client`: Replay only
15
+ * exists in the browser build of `@sentry/nextjs`, and Turbopack fails the edge bundle on it.
16
+ */
17
+ export declare function sentryServerOptions({ dsn, enabled, environment, tracesSampleRate, ignore, }: SentryPresetOptions): {
18
+ dsn: string | undefined;
19
+ enabled: boolean;
20
+ environment: string | undefined;
21
+ tracesSampleRate: number;
22
+ sendDefaultPii: false;
23
+ enableLogs: true;
24
+ beforeSend: (event: ErrorEvent, hint: EventHint) => ErrorEvent | null;
25
+ };
26
+ export {};
package/dist/sentry.js ADDED
@@ -0,0 +1,33 @@
1
+ import { isTransientUncaughtException } from "@inbeat/core/errors";
2
+ const EXTENSION_FRAME = /(extension:\/\/|\.safariextension|^safari-extension|^moz-extension|^chrome-extension)/;
3
+ function hasExtensionFrame(event) {
4
+ return (event.exception?.values?.some((value) => value.stacktrace?.frames?.some((frame) => EXTENSION_FRAME.test(frame.filename ?? ""))) ?? false);
5
+ }
6
+ function createBeforeSend(ignore) {
7
+ return (event, hint) => {
8
+ if (ignore?.(hint))
9
+ return null;
10
+ if (isTransientUncaughtException(event.exception?.values?.[0]?.mechanism?.type, hint.originalException)) {
11
+ return null;
12
+ }
13
+ if (hasExtensionFrame(event))
14
+ return null;
15
+ return event;
16
+ };
17
+ }
18
+ /**
19
+ * Server and edge options. Browser options live in `@inbeat/next/sentry-client`: Replay only
20
+ * exists in the browser build of `@sentry/nextjs`, and Turbopack fails the edge bundle on it.
21
+ */
22
+ export function sentryServerOptions({ dsn, enabled, environment, tracesSampleRate = 0.1, ignore, }) {
23
+ return {
24
+ dsn,
25
+ enabled: enabled && Boolean(dsn),
26
+ environment,
27
+ tracesSampleRate,
28
+ // Creator and client PII (names, emails, rates) must not leave for a third party by default.
29
+ sendDefaultPii: false,
30
+ enableLogs: true,
31
+ beforeSend: createBeforeSend(ignore),
32
+ };
33
+ }
package/package.json ADDED
@@ -0,0 +1,117 @@
1
+ {
2
+ "name": "@inbeat/next",
3
+ "version": "0.7.0",
4
+ "description": "Next.js App Router building blocks for inBeat apps: actions, routes, security, observability",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "files": [
8
+ "CHANGELOG.md",
9
+ "dist"
10
+ ],
11
+ "exports": {
12
+ "./action": {
13
+ "types": "./dist/action.d.ts",
14
+ "default": "./dist/action.js"
15
+ },
16
+ "./route": {
17
+ "types": "./dist/route.d.ts",
18
+ "default": "./dist/route.js"
19
+ },
20
+ "./capture": {
21
+ "types": "./dist/capture.d.ts",
22
+ "default": "./dist/capture.js"
23
+ },
24
+ "./clerk-webhook": {
25
+ "types": "./dist/clerk-webhook.d.ts",
26
+ "default": "./dist/clerk-webhook.js"
27
+ },
28
+ "./db": {
29
+ "types": "./dist/db.d.ts",
30
+ "default": "./dist/db.js"
31
+ },
32
+ "./maintenance": {
33
+ "types": "./dist/maintenance.d.ts",
34
+ "default": "./dist/maintenance.js"
35
+ },
36
+ "./rate-limit": {
37
+ "types": "./dist/rate-limit.d.ts",
38
+ "default": "./dist/rate-limit.js"
39
+ },
40
+ "./security-headers": {
41
+ "types": "./dist/security-headers.d.ts",
42
+ "default": "./dist/security-headers.js"
43
+ },
44
+ "./sentry": {
45
+ "types": "./dist/sentry.d.ts",
46
+ "default": "./dist/sentry.js"
47
+ },
48
+ "./sentry-client": {
49
+ "types": "./dist/sentry-client.d.ts",
50
+ "default": "./dist/sentry-client.js"
51
+ },
52
+ "./safe-redirect": {
53
+ "types": "./dist/safe-redirect.d.ts",
54
+ "default": "./dist/safe-redirect.js"
55
+ },
56
+ "./fonts": {
57
+ "types": "./dist/fonts.d.ts",
58
+ "default": "./dist/fonts.js"
59
+ }
60
+ },
61
+ "scripts": {
62
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
63
+ "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
64
+ "typecheck": "tsc --noEmit",
65
+ "test": "vitest run"
66
+ },
67
+ "dependencies": {
68
+ "@inbeat/core": "^0.7.0",
69
+ "@upstash/ratelimit": "^2.0.6",
70
+ "@upstash/redis": "^1.35.3",
71
+ "server-only": "^0.0.1"
72
+ },
73
+ "peerDependencies": {
74
+ "@clerk/nextjs": "^7",
75
+ "@neondatabase/serverless": "^1",
76
+ "@sentry/nextjs": "^10",
77
+ "drizzle-orm": ">=0.44",
78
+ "geist": "^1",
79
+ "next": ">=16",
80
+ "react": "^19",
81
+ "zod": "^4"
82
+ },
83
+ "peerDependenciesMeta": {
84
+ "@clerk/nextjs": {
85
+ "optional": true
86
+ },
87
+ "@neondatabase/serverless": {
88
+ "optional": true
89
+ },
90
+ "drizzle-orm": {
91
+ "optional": true
92
+ }
93
+ },
94
+ "devDependencies": {
95
+ "@clerk/nextjs": "^7.0.6",
96
+ "@inbeat/config": "^0.7.0",
97
+ "@neondatabase/serverless": "^1.0.2",
98
+ "@sentry/nextjs": "^10",
99
+ "@types/react": "^19",
100
+ "drizzle-orm": "^0.45.1",
101
+ "geist": "^1.7.2",
102
+ "next": "16.3.8",
103
+ "react": "19.2.3",
104
+ "react-dom": "19.2.3",
105
+ "vitest": "^4.1.1",
106
+ "zod": "^4.3.6"
107
+ },
108
+ "repository": {
109
+ "type": "git",
110
+ "url": "git+https://github.com/Genoux/ib-toolkit.git",
111
+ "directory": "packages/next"
112
+ },
113
+ "publishConfig": {
114
+ "access": "public"
115
+ },
116
+ "license": "UNLICENSED"
117
+ }