@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 +3 -1
- package/bin/nk.js +2 -1
- package/guide.md +5 -5
- package/lib/artifacts.js +1 -1
- package/lib/auth-shadow.js +150 -0
- package/lib/doctor.js +6 -1
- package/lib/oxlint-plugins/no-crypto-random-uuid.js +3 -3
- package/package.json +6 -6
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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 `
|
|
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 ->
|
|
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 `
|
|
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.
|
|
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.
|
|
48
|
+
"@ast-grep/cli": "^0.45.1",
|
|
49
49
|
"@testing-library/dom": "^10.4.1",
|
|
50
|
-
"@testing-library/jest-dom": "^7.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.
|
|
54
|
-
"oxfmt": "^0.
|
|
55
|
-
"oxlint": "^1.
|
|
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
|
},
|