@ingram-tech/nk-dev 0.11.0 → 0.11.1

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/README.md CHANGED
@@ -82,7 +82,9 @@ tsc), so versions stay under each site's control — nk just orchestrates.
82
82
  - **`nk doctor [--fix]`** — report drift from the canonical nk-dev toolchain
83
83
  (superseded deps, config `extends`, package.json scripts, the agent-guide
84
84
  import, a stale `.prettierignore`, an unsealed migration chain and the DDL in
85
- it drizzle can't model); `--fix` applies the auto-fixable findings.
85
+ it drizzle can't model, a page under `app/auth/` shadowing a Better Auth
86
+ endpoint — static segments beat the `[...all]` catch-all, so such a page
87
+ silently 405s the endpoint); `--fix` applies the auto-fixable findings.
86
88
  - **`nk dev`** — start the Next dev server on the golden-path local database
87
89
  (see [`db-package.md`](https://github.com/ingram-technologies/nextkit/blob/main/docs/db-package.md)):
88
90
  - **PGlite** — if `@ingram-tech/nk-db`'s `nk-pglite-dev` bin resolves, hand off
package/bin/nk.js CHANGED
@@ -17,7 +17,8 @@ Commands:
17
17
  oxfmt / TypeScript / Vitest config, the format-on-commit
18
18
  hook, and the agent-guide import. Skips files that exist.
19
19
  doctor [--fix] Report drift from the canonical nk-dev toolchain (scripts,
20
- superseded deps, config extends, guide import); --fix applies.
20
+ superseded deps, config extends, guide import, auth pages
21
+ shadowing Better Auth endpoints); --fix applies.
21
22
  dev Start the Next dev server (Turbopack). Boots local PGlite
22
23
  first when @ingram-tech/nk-db is installed (no Docker).
23
24
  format [--check] Format code with oxfmt. --check verifies without writing.
package/guide.md CHANGED
@@ -66,10 +66,10 @@ the UI/page tree, and never expose internal plumbing under `/api/`.
66
66
  (`uuid("id").primaryKey().default(sql\`uuidv7()\`)`) and Better Auth gets
67
67
  `advanced.database.generateId: false` so the DB mints ids; below 18 — and in
68
68
  the nk-auth README's canonical example — pass
69
- `advanced.database.generateId: uuidGenerateId` (JS-minted UUIDv7 from
70
- `@ingram-tech/nk-auth`) instead. Either way, never Better Auth's default JS
69
+ `advanced.database.generateId: uuidv7` (JS-minted UUIDv7 from `id758`;
70
+ `@ingram-tech/nk-auth` re-exports it as `uuidGenerateId`) instead. Either way, never Better Auth's default JS
71
71
  nanoid. Ids that cross a **public contract** are skinned to `prefix_base58`
72
- via `@ingram-tech/nk-db/id` (`createIdRegistry`) — never expose a raw UUID.
72
+ via `id758` / `@ingram-tech/nk-db/id` (`createIdRegistry`) — never expose a raw UUID.
73
73
  External ids you don't mint (Stripe `cus_`, OAuth) stay `text`.
74
74
  - **Migrations don't auto-apply on deploy.** Code ships ahead of the prod schema
75
75
  unless someone runs the migration against the target DB — a page that reads a
@@ -139,12 +139,12 @@ tool instead). One-off single-file edits: just edit the file.
139
139
 
140
140
  - `@ingram-tech/nk-email` — Cloudflare email: `sendEmail`, `fromAddress`
141
141
  - `@ingram-tech/nk-auth` — Better Auth foundation: presets you spread into your own `betterAuth()` (mounts at `/auth` via `authBasePath`; org / JWT / passkey / pool / client helpers). Don't hand-roll session reads or auth middleware — bind `createAuthHelpers` (`getUser` / `requireUser` / `redirectIfAuthenticated`, from `@ingram-tech/nk-auth/server`) and gate routes with the loop-safe `createAuthMiddleware`
142
- - `@ingram-tech/nk-db` — Postgres data layer: `createPool` (one TLS-aware pool) + `createQueries` (raw SQL) + `createDb` (Drizzle), the PGlite dev/test harness at `@ingram-tech/nk-db/pglite`, the prefixed-id codec at `@ingram-tech/nk-db/id`, and the drift-aware migration runner at `@ingram-tech/nk-db/migrate`
142
+ - `@ingram-tech/nk-db` — Postgres data layer: `createPool` (one TLS-aware pool) + `createQueries` (raw SQL) + `createDb` (Drizzle), the PGlite dev/test harness at `@ingram-tech/nk-db/pglite`, the prefixed-id codec (the standalone `id758` package) at `@ingram-tech/nk-db/id`, and the drift-aware migration runner at `@ingram-tech/nk-db/migrate`
143
143
  - `@ingram-tech/nk-api` — the standard HTTP API seam (Hono + `@hono/zod-openapi`): one `{ error, details? }` envelope, `createApiApp` / `createRouter`, auth + multi-tenant resource-scope middleware, pagination helpers, and an emitted OpenAPI/Swagger doc. Reach for it instead of hand-rolling route handlers
144
144
  - `@ingram-tech/nk-billing` — Stripe primitives: subscriptions, a Stripe-side wallet, and an optional Postgres credit ledger behind the `/credits` subpath. Prices resolve at runtime by Stripe `lookup_key` — **never hardcode a price id**, so test and live share one code path
145
145
  - `@ingram-tech/bot-protection` — invisible form protection (honeypot + timing + Vercel BotID); the primitive nk-forms builds on, used directly only for non-form endpoints
146
146
  - `@ingram-tech/nk-forms` — the public contact/signup submission pipeline over bot-protection + nk-email: `handleFormSubmission` (rate-limit → bot gate → validate → escaped-email deliver → uniform 200), `renderNotificationEmail`, `mintFormToken`, and `useFormSubmit` / `HoneypotInput` (`/react`). Reach for it instead of wiring bot-protection by hand
147
- - `@ingram-tech/nk-i18n` — type-safe, English-as-key i18n: the English source text *is* the key (no `en.json`), ICU MessageFormat, colocated JSON catalogs; routing is left to the site
147
+ - `@ingram-tech/nk-i18n` — type-safe, English-as-key i18n: the English source text *is* the key (no `en.json`), ICU MessageFormat, colocated JSON catalogs, plus **locale URL routing** (`defineLocaleRouting` + a fixed URL→account→cookie→`Accept-Language`→country precedence, wired to Next at `/next`). A URL that names a locale must serve it with a 200 — never redirect `?hl=fr` away, or every hreflang annotation on the site points at a URL that doesn't serve the language it claims. See `docs/i18n-routing.md`
148
148
  - `@ingram-tech/nk-marketing` — Postgres-backed marketing & lifecycle email: contacts + consent, newsletter broadcast audiences, and idempotent triggered campaigns, with RFC 8058 one-click unsubscribe
149
149
  - `@ingram-tech/nk-seo` — SEO toolkit: metadata factory, JSON-LD builders, sitemap/robots routes, hreflang + canonical links, and an OG image template
150
150
  - `@ingram-tech/nk-blog` — file-indexed blog engine: frontmatter contract, limited-MDX rendering with a component vocabulary, RSS, blog SEO, GitHub publishing
package/lib/artifacts.js CHANGED
@@ -17,7 +17,7 @@ const GENERATED_DIRECTORIES = [
17
17
  ];
18
18
 
19
19
  /** Prefixes (posix-normalised) that `tsc` error locations may fall inside. */
20
- export const TYPE_CHECK_INPUT_PREFIXES = GENERATED_DIRECTORIES.filter(
20
+ const TYPE_CHECK_INPUT_PREFIXES = GENERATED_DIRECTORIES.filter(
21
21
  (entry) => entry.typeCheckInput,
22
22
  ).map((entry) => entry.path);
23
23
 
@@ -0,0 +1,150 @@
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { dirname, join, resolve } from "node:path";
4
+
5
+ // In the App Router a static segment always beats a catch-all, so a page (or
6
+ // route.ts) under `app/auth/` whose path matches a Better Auth endpoint
7
+ // silently shadows it: GETs render the page, POSTs to the endpoint return 405,
8
+ // and nothing at build time says so. The endpoint list is derived textually
9
+ // from better-auth's dist (grep for `createAuthEndpoint("...")`) — we never
10
+ // load or execute site or dependency code just to read a set of strings.
11
+
12
+ const PAGE_FILES = /^page\.(tsx|jsx|ts|js)$/;
13
+ const ROUTE_FILES = /^route\.(ts|js)$/;
14
+ const ENDPOINT_RE = /createAuthEndpoint\(\s*"([^"]+)"/g;
15
+
16
+ /** The `app/auth/[...all]` mount dir, or null when the site has no auth mount. */
17
+ function findMount(cwd) {
18
+ for (const appDir of ["src/app", "app"]) {
19
+ const catchAll = resolve(cwd, appDir, "auth", "[...all]");
20
+ for (const ext of ["ts", "js", "tsx", "jsx"]) {
21
+ if (existsSync(join(catchAll, `route.${ext}`))) {
22
+ return { appDir, authDir: resolve(cwd, appDir, "auth") };
23
+ }
24
+ }
25
+ }
26
+ return null;
27
+ }
28
+
29
+ /** better-auth's dist dir resolved from the site, or null when not installed. */
30
+ function betterAuthDist(cwd) {
31
+ try {
32
+ const require = createRequire(resolve(cwd, "package.json"));
33
+ const pkg = require.resolve("better-auth/package.json");
34
+ return join(dirname(pkg), "dist");
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ /** All `createAuthEndpoint("...")` paths in the `.mjs` files under `dir`. */
41
+ function grepEndpoints(dir, recurse) {
42
+ if (!existsSync(dir)) return [];
43
+ const paths = new Set();
44
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
45
+ const full = join(dir, entry.name);
46
+ if (entry.isDirectory()) {
47
+ if (recurse) for (const p of grepEndpoints(full, true)) paths.add(p);
48
+ continue;
49
+ }
50
+ if (!entry.name.endsWith(".mjs")) continue;
51
+ const src = readFileSync(full, "utf8");
52
+ for (const m of src.matchAll(ENDPOINT_RE)) paths.add(m[1]);
53
+ }
54
+ return [...paths];
55
+ }
56
+
57
+ /**
58
+ * Walk `app/auth/**` collecting the page/route files that claim a static URL,
59
+ * as `{ file, segments }` with `file` relative to `cwd`. Skips the `[...all]`
60
+ * catch-all itself, `_private` folders, and `@slot` parallel-route trees (a
61
+ * slot renders alongside the layout rather than owning the URL segment, so we
62
+ * conservatively leave those trees to the human); `(group)` segments don't
63
+ * appear in the URL and are dropped.
64
+ */
65
+ function collectRoutes(cwd, dir, segments, out) {
66
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
67
+ const full = join(dir, entry.name);
68
+ if (entry.isDirectory()) {
69
+ if (entry.name === "[...all]") continue;
70
+ if (entry.name.startsWith("_") || entry.name.startsWith("@")) continue;
71
+ const next = /^\(.*\)$/.test(entry.name)
72
+ ? segments
73
+ : [...segments, entry.name];
74
+ collectRoutes(cwd, full, next, out);
75
+ continue;
76
+ }
77
+ if (!PAGE_FILES.test(entry.name) && !ROUTE_FILES.test(entry.name)) continue;
78
+ if (segments.length === 0) continue; // `/auth` itself can't match an endpoint
79
+ out.push({ file: full.slice(cwd.length + 1), segments });
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Whether a page's segments match an endpoint path: same segment count, where
85
+ * an endpoint `:param` matches any page segment and a page `[param]` (or
86
+ * catch-all) matches any endpoint segment.
87
+ */
88
+ function shadows(segments, endpoint) {
89
+ const eps = endpoint.split("/").filter(Boolean);
90
+ if (eps.length !== segments.length) return false;
91
+ return eps.every((ep, i) => {
92
+ const seg = segments[i];
93
+ if (ep.startsWith(":")) return true;
94
+ if (/^\[.*\]$/.test(seg)) return true;
95
+ return seg === ep;
96
+ });
97
+ }
98
+
99
+ /**
100
+ * Findings for Better Auth endpoint shadowing. Silent on sites without an
101
+ * `app/auth/[...all]` mount or without better-auth installed. Core endpoints
102
+ * (dist/api/routes) shadow as errors; plugin endpoints (dist/plugins) as
103
+ * warnings, since only enabled plugins are live and we can't tell which those
104
+ * are without executing the site's auth config.
105
+ */
106
+ export function authShadowFindings(cwd) {
107
+ const mount = findMount(cwd);
108
+ if (!mount) return [];
109
+ const dist = betterAuthDist(cwd);
110
+ if (!dist) return [];
111
+
112
+ const core = grepEndpoints(join(dist, "api", "routes"), false);
113
+ if (core.length === 0) {
114
+ return [
115
+ {
116
+ id: "auth:shadow-check-skipped",
117
+ level: "warn",
118
+ message:
119
+ "could not derive Better Auth's endpoint list from better-auth/dist/api/routes (layout changed?) — the endpoint-shadowing check was skipped",
120
+ },
121
+ ];
122
+ }
123
+ const plugin = grepEndpoints(join(dist, "plugins"), true);
124
+
125
+ const routes = [];
126
+ collectRoutes(cwd, mount.authDir, [], routes);
127
+
128
+ const out = [];
129
+ for (const { file, segments } of routes) {
130
+ const routePath = `/${segments.join("/")}`;
131
+ const hit = core.find((ep) => shadows(segments, ep));
132
+ if (hit) {
133
+ out.push({
134
+ id: `auth:endpoint-shadow:${routePath}`,
135
+ level: "error",
136
+ message: `\`${file}\` shadows Better Auth's \`/auth${hit}\` endpoint — a static segment beats the \`[...all]\` catch-all, so POSTs to it return 405 and the auth flow silently breaks. Rename the page (the precedent: the reset page is \`/auth/set-password\` because \`/auth/reset-password\` is taken).`,
137
+ });
138
+ continue;
139
+ }
140
+ const pluginHit = plugin.find((ep) => shadows(segments, ep));
141
+ if (pluginHit) {
142
+ out.push({
143
+ id: `auth:endpoint-shadow-plugin:${routePath}`,
144
+ level: "warn",
145
+ message: `\`${file}\` would shadow the Better Auth plugin endpoint \`/auth${pluginHit}\` — only a problem if the site enables that plugin, but a rename now avoids the 405 later.`,
146
+ });
147
+ }
148
+ }
149
+ return out;
150
+ }
package/lib/doctor.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
+ import { authShadowFindings } from "./auth-shadow.js";
3
4
  import { SUPERSEDED_DEPS } from "./drift.js";
4
5
  import {
5
6
  SEAL_FILE,
@@ -269,6 +270,9 @@ export function findings(cwd) {
269
270
  // 9. The migration chain is sealed, and its unmodelled DDL is declared.
270
271
  out.push(...migrationFindings(cwd));
271
272
 
273
+ // 10. No page/route under app/auth/ shadows a Better Auth endpoint.
274
+ out.push(...authShadowFindings(cwd));
275
+
272
276
  return out;
273
277
  }
274
278
 
@@ -329,7 +333,8 @@ function migrationFindings(cwd) {
329
333
  * `nk doctor [--fix]` — report drift from the canonical nk-dev model (scripts,
330
334
  * dependencies, oxlint/tsconfig extends, the CLAUDE.md guide import, stale knip
331
335
  * ignores, forbidden schema-applying drizzle-kit scripts, a dead
332
- * .prettierignore, an unsealed or unmodelled-DDL-carrying migration chain).
336
+ * .prettierignore, an unsealed or unmodelled-DDL-carrying migration chain, a
337
+ * page under app/auth/ shadowing a Better Auth endpoint).
333
338
  * With `--fix`, apply every auto-fixable finding, then remind
334
339
  * to reinstall.
335
340
  */
@@ -8,7 +8,7 @@
8
8
  // invisible until the table is large, which is exactly when it is expensive to
9
9
  // undo.
10
10
  //
11
- // The mint is `uuidGenerateId()` from `@ingram-tech/nk-db/id`, already typed
11
+ // The mint is `uuidv7()` from `id758` (re-exported by `@ingram-tech/nk-db/id`), already typed
12
12
  // `Uuid`. Most rows need no mint at all: `uuid("id").primaryKey().default(sql`
13
13
  // `uuidv7()`)` lets the database do it, and the app only mints when it needs the
14
14
  // id *before* the insert (a client-chosen document PK it must also use as the
@@ -17,7 +17,7 @@
17
17
  // Deliberately not autofixable. The right replacement depends on what the value
18
18
  // is, and one of the answers is "leave it alone":
19
19
  //
20
- // - a stored id -> uuidGenerateId(), or drop it for the column default
20
+ // - a stored id -> uuidv7(), or drop it for the column default
21
21
  // - a bearer token / nonce -> keep crypto.randomUUID()
22
22
  //
23
23
  // v7 is the *wrong* choice for a secret. It spends 48 bits on a millisecond
@@ -77,7 +77,7 @@ const noCryptoRandomUuid = {
77
77
  },
78
78
  messages: {
79
79
  cryptoRandomUuid:
80
- "`crypto.randomUUID()` is UUIDv4; stored ids are UUIDv7. Mint with `uuidGenerateId()` from `@ingram-tech/nk-db/id`, or omit the id and let the `uuidv7()` column default apply. If this is a bearer token or nonce, keep v4 and add `// oxlint-disable-next-line nextkit/no-crypto-random-uuid -- <reason>`.",
80
+ "`crypto.randomUUID()` is UUIDv4; stored ids are UUIDv7. Mint with `uuidv7()` from `id758` (re-exported by `@ingram-tech/nk-db/id`), or omit the id and let the `uuidv7()` column default apply. If this is a bearer token or nonce, keep v4 and add `// oxlint-disable-next-line nextkit/no-crypto-random-uuid -- <reason>`.",
81
81
  },
82
82
  },
83
83
  create(context) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ingram-tech/nk-dev",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "description": "The nextkit dev toolchain in one package: the `nk` CLI plus shared oxlint/oxfmt, TypeScript, and Vitest config, the format-on-commit hook, and the AI agent guide. `nk init` scaffolds a site to use it.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -45,14 +45,14 @@
45
45
  "test": "vitest run"
46
46
  },
47
47
  "dependencies": {
48
- "@ast-grep/cli": "^0.45.0",
48
+ "@ast-grep/cli": "^0.45.1",
49
49
  "@testing-library/dom": "^10.4.1",
50
- "@testing-library/jest-dom": "^7.0.0",
50
+ "@testing-library/jest-dom": "^7.0.1",
51
51
  "@typescript/native": "npm:typescript@^7.0.2",
52
52
  "jsdom": "^30.0.1",
53
- "knip": "^6.31.0",
54
- "oxfmt": "^0.61.0",
55
- "oxlint": "^1.76.0",
53
+ "knip": "^6.32.2",
54
+ "oxfmt": "^0.63.0",
55
+ "oxlint": "^1.78.0",
56
56
  "typescript": "npm:@typescript/typescript6@^6.0.2",
57
57
  "vitest": "^4.1.10"
58
58
  },