navori 0.7.0 → 0.7.2

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.
@@ -334,6 +334,60 @@ else
334
334
  skeleton="${cmd}${_nl}"
335
335
  fi
336
336
 
337
+ # ─── Fast path: a command no rule below can possibly match ───────────────────
338
+ #
339
+ # WHY: this guard runs on EVERY Bash call, and in auto mode the host tells the
340
+ # agent to work through the shell — so it is in front of reads, greps and edits
341
+ # alike. Measured, it costs ~46 ms against a ~2 ms process floor: the other
342
+ # ~44 ms is the `sed`/`grep` pipeline below, roughly forty forks. Over one
343
+ # audited session that is 901 invocations and ~40 s of wall clock spent proving
344
+ # that `cat file` is not `rm -rf /`. On 9,398 real commands from that session,
345
+ # 80% contain none of the tokens below.
346
+ #
347
+ # WHY IT IS SOUND, which is the only part that matters in a security control:
348
+ # every `block` in this file needs one of these literal substrings to survive
349
+ # into the string its rule reads — `git` (rules 1-2, via `$git_cp`), `rm`
350
+ # (rule 3), `:(` (rule 4), `/dev/` (rule 5), and `>`/`sed`/`tee` (rule 6, the
351
+ # three write verbs it recognizes). No rule can fire without one.
352
+ #
353
+ # The probe is the command with quotes, backslashes and newlines REMOVED, which
354
+ # is what makes the argument hold under the obfuscations the rules normalize
355
+ # away: `r'm' -rf ~` becomes `rm -rf ~`, `\git` becomes `git`, and a `g\<NL>it`
356
+ # split across a continuation is rejoined. Removal is the safe direction on
357
+ # purpose — none of the tokens contains a quote, a backslash or a newline, so
358
+ # stripping those can only CREATE matches, never destroy one. A false match
359
+ # costs the full analysis; a false miss would be a hole, and cannot happen here.
360
+ #
361
+ # PLACEMENT is load-bearing too: this sits AFTER the three fail-closed size
362
+ # limits (`CMD_MAX`, `LINE_MAX`, `HEREDOC_PROBE_MAX`) so a command too large to
363
+ # inspect is still blocked, exactly as before. It skips the rule machinery, never
364
+ # a refusal. And it reads `$cmd`, not `$skeleton`: the skeleton drops inert
365
+ # heredoc bodies, so scanning it would narrow what the probe sees.
366
+ #
367
+ # FAST_MAX exists because `${var//pat/}` is NOT linear in bash: on a string past
368
+ # ~64 KB with thousands of matches it goes superlinear hard — measured, 1024
369
+ # lines of quoted `echo` strip instantly and 2048 lines had not finished after
370
+ # nine minutes. An optimization that hangs the guard is a guard that hangs, so
371
+ # the probe is only built for a command small enough for the cost to be free.
372
+ # Above it, the full analysis runs exactly as it did before this block existed.
373
+ # 4096 covers 98.5% of 1,089 real commands from the measured session (p50 370,
374
+ # p90 1650) and sits sixteen times below the cliff.
375
+ FAST_MAX=4096
376
+ if [ "${#cmd}" -le "$FAST_MAX" ]; then
377
+ _fast=${cmd//\'/}
378
+ _fast=${_fast//\"/}
379
+ _fast=${_fast//\\/}
380
+ _fast=${_fast//"${_nl}"/}
381
+ case "$_fast" in
382
+ *git*|*rm*|*sed*|*tee*|*'/dev/'*|*'>'*|*':('*) ;;
383
+ *)
384
+ navori_audit_verdict="skip"
385
+ navori_audit_reason="no rule token in the command"
386
+ exit 0
387
+ ;;
388
+ esac
389
+ fi
390
+
337
391
  # A `$(…)`/backtick substitution outside single quotes RUNS: `git commit -m
338
392
  # "$(rm -rf ~)"` is an invocation wearing a message's clothes. This check must
339
393
  # read the skeleton BEFORE the flag values are elided below — eliding first
@@ -6,36 +6,44 @@ type: reference
6
6
 
7
7
  # Zod Validation — the canonical pattern
8
8
 
9
- One schema per resource (`<resource>.schema.ts`), validated by a generic middleware that replaces `req[target]` with the parsed and typed value. The DTO comes from `z.infer`.
9
+ One schema per resource (`<resource>.schema.ts`), a generic validate middleware, and the DTO from `z.infer`.
10
10
 
11
11
  ## When to use this skill
12
12
 
13
- When creating a schema, adding validation to an endpoint, inferring a DTO, or touching input from body/query/params.
13
+ When creating a schema, adding validation to an endpoint, inferring a DTO, or touching body/query/params input.
14
+
15
+ **Check the installed major** (`package.json`): snippets are **Zod 4**, with the v3 form annotated inline where they differ.
14
16
 
15
17
  ## The pattern
16
18
 
17
- A single shared middleware (Express shown here) parses `req[target]` against the schema, and on failure throws `BadRequestError(\`${path}: ${first.message}\`)` with the first issue. On success, it reassigns `req[target] = parsed`. Schema and DTO:
19
+ The middleware (Express here) parses `req[target]`, throws `BadRequestError` from the first issue, or reassigns `req[target] = parsed`:
18
20
 
19
21
  ```ts
20
- const objectId = z.string().regex(/^[a-f\d]{24}$/i, 'Invalid ObjectId');
21
-
22
22
  export const createResourceSchema = z.object({
23
- owner: objectId,
24
- resourceType: z.nativeEnum(ResourceTypeEnum),
23
+ owner: z.uuid(), // v3: z.string().uuid()
24
+ resourceType: z.enum(ResourceTypeEnum), // v3: z.nativeEnum(ResourceTypeEnum)
25
25
  page: z.coerce.number().int().positive().default(1)
26
26
  });
27
27
  export const updateResourceSchema = createResourceSchema.partial();
28
28
  export type CreateResourceDto = z.infer<typeof createResourceSchema>;
29
29
  ```
30
30
 
31
- In the route: `router.post('/', validate(createResourceSchema, 'body'), ...)`. In the controller the cast `req.body as CreateResourceDto` is safe because the middleware already parsed it.
31
+ Route: `router.post('/', validate(createResourceSchema, 'body'), ...)`. The controller's `req.body as CreateResourceDto` cast is safe: already parsed.
32
+
33
+ `safeParse` failure → readable 4xx (v4):
34
+
35
+ ```ts
36
+ const parsed = schema.safeParse(req.body);
37
+ if (!parsed.success) return res.status(400).json({ error: z.prettifyError(parsed.error) });
38
+ ```
39
+
40
+ `z.prettifyError(e)` → readable string; `z.treeifyError(e)` → input-shaped object for per-field errors; `e.issues` → raw array (both majors). **v3 has neither:** `e.format()` / `e.flatten()`.
32
41
 
33
42
  ## Gotchas that bite
34
43
 
35
- - **A bare ObjectId** (`z.string()`) lets `"abc"` through; Mongoose throws a CastError 500 instead of a clean 400. Always use the `objectId` helper.
36
- - **Query strings are always strings.** Without `z.coerce`, `z.number()` rejects them. Use `z.coerce.number()` / `z.coerce.date()`. **Footgun:** `z.coerce.number()` uses `Number()`, so `""`/`" "`/`null` → `0` (an empty `?page=` passes as `0`). If it matters, set explicit bounds or `z.string().regex(...).transform(Number)`.
37
- - **Unknown keys are silently dropped:** `z.object({...})` *strips*, so a typo in the body (`{ ammount }`) is lost with no error. On mutation endpoints use `z.strictObject({...})` to catch it.
38
- - **Version:** this skill assumes Zod v3. In **v4**: `z.nativeEnum`→`z.enum`, `z.string().datetime()`→`z.iso.datetime()`, and `{ message }`→`{ error }` in the error options.
44
+ - **A bare id** (`z.string()`) lets `"abc"` through and the layer below breaks on it — a driver cast error becomes a 500 instead of a clean 400. Validate the id's *shape*: `z.uuid()`, `z.cuid()`, `z.coerce.number().int()` (serial) or `.regex(...)`. *Mongo:* `z.string().regex(/^[a-f\d]{24}$/i, 'Invalid ObjectId')` — see the `mongoose` skill.
45
+ - **Query strings are always strings.** Without `z.coerce`, `z.number()` rejects them. **Footgun:** `z.coerce.number()` uses `Number()`, so `""`/`" "`/`null` → `0` (an empty `?page=` passes as `0`); set explicit bounds or `z.string().regex(...).transform(Number)`.
46
+ - **Unknown keys are silently dropped:** `z.object({...})` *strips*, so a typo in the body (`{ ammount }`) is lost with no error. On mutation endpoints use `z.strictObject({...})`.
39
47
 
40
48
  ## Hard rules
41
49
 
@@ -43,26 +51,29 @@ In the route: `router.post('/', validate(createResourceSchema, 'body'), ...)`. I
43
51
  2. The schema lives in `<resource>.schema.ts`, never in the routes.
44
52
  3. DTO always with `z.infer` — don't maintain two parallel types.
45
53
  4. No `z.any()`: it equals `any`, forbidden in new code.
46
- 5. A single validator per endpoint — don't mix Joi + Zod (when migrating Joi→Zod, migrate the whole endpoint).
47
- 6. Mongo ObjectId with the `objectId` helper; query/params with `z.coerce`.
54
+ 5. One validator per endpoint — no Joi + Zod mix; migrate the whole endpoint.
55
+ 6. Ids validated by shape, never a bare `z.string()`; query/params with `z.coerce`.
48
56
 
49
57
  ## Quick table
50
58
 
59
+ `v4 · v3` where they differ.
60
+
51
61
  | Need to validate | Helper |
52
62
  |---|---|
53
- | ObjectId | `objectId` (regex `/^[a-f\d]{24}$/i`) |
63
+ | Id | shape-specific, never `z.string()`: `z.uuid()` · `z.string().uuid()` |
54
64
  | Non-empty string | `z.string().trim().min(1)` |
55
65
  | Number from query | `z.coerce.number().int().positive()` |
56
- | Date | `z.coerce.date()` or `z.string().datetime()` |
57
- | TS enum / literal | `z.nativeEnum(MyEnum)` / `z.enum(['a','b'])` |
66
+ | Date | `z.coerce.date()`, `z.iso.datetime()` · `z.string().datetime()` |
67
+ | TS enum / literal | `z.enum(MyEnum)` · `z.nativeEnum(MyEnum)`; `z.enum(['a','b'])` |
58
68
  | Partial update | `createSchema.partial()` |
59
- | Cross-field validation | `.refine((d) => ..., { message, path })` |
69
+ | Cross-field validation | `.refine((d) => ..., { error, path })` · `{ message, path }` |
70
+ | Error → 4xx body | `z.prettifyError(e)` · `e.format()` |
60
71
 
61
72
  ## Before declaring done
62
73
 
63
- - The schema lives in `<resource>.schema.ts` and the DTO comes from `z.infer`.
64
- - The endpoint uses `validate(schema, target)`; no inline validation in the controller.
65
- - ObjectId fields with the `objectId` helper; query fields with `z.coerce`.
74
+ - Schema in `<resource>.schema.ts`, DTO from `z.infer`, endpoint wired with `validate(schema, target)` — no inline validation in the controller.
75
+ - Ids validated by shape, not a bare `z.string()`; query fields with `z.coerce`.
76
+ - APIs match the installed major — no `z.nativeEnum` on v4, no `z.prettifyError` on v3.
66
77
  - `{{qualityGate.fast}}` green.
67
78
 
68
79
  <!-- navori:user-section -->
@@ -0,0 +1,56 @@
1
+ ---
2
+ name: keystone-graphql
3
+ description: Custom GraphQL in Keystone 6 — extendGraphqlSchema, the project-scoped gWithContext<Context>() builder, and the access guard every custom resolver must open with. Use when adding or touching a custom mutation, query or resolver.
4
+ type: reference
5
+ ---
6
+
7
+ # Keystone Custom GraphQL — extendGraphqlSchema
8
+
9
+ ## When to use this skill
10
+
11
+ Before adding or changing a custom mutation/query, or when debugging a resolver that won't type-check. A custom resolver does **not** go through the list's `access`: the guard it doesn't write is a guard nobody writes.
12
+
13
+ ## The builder must carry the project `Context`
14
+
15
+ Keystone 8 renamed `graphql` to `g` and changed how it's typed. Importing `g`/`graphql` straight from `@keystone-6/core` gives `GWithContext<KeystoneContext>` — bound to Keystone's **base** context, not the project's generated `Context`. Resolvers are typed against that generated `Context`, so a field built with the unparametrized builder fails to unify (`TS2322`/`TS2345`): the two contexts are structurally similar but nominally distinct types.
16
+
17
+ Keystone's documented fix: **one project module builds `gWithContext<Context>()` once**, and everything imports `g` from there. Never import `graphql`/`g` from `@keystone-6/core` in project code.
18
+
19
+ **One exception — `virtual()` fields on a list.** Keystone types their `field` as `VirtualFieldGraphQLField<BaseItem, KeystoneContext<BaseKeystoneTypeInfo>>`, against the base context, so the project-scoped `g` yields a type that does not unify with it (`TS2322`). Those import `g` from `@keystone-6/core` directly, with a comment saying why; everything under `extendGraphqlSchema` uses the shared builder.
20
+
21
+ ## The pattern: pure resolver + thin field
22
+
23
+ ```ts
24
+ /** Accepts an open appeal. Only a moderator may resolve one. */
25
+ export async function resolveAcceptAppeal(
26
+ args: { appealId: string },
27
+ context: Context, // the GENERATED Context, not KeystoneContext
28
+ ): Promise<AppealResult> {
29
+ requireModerator(context, "…"); // guard FIRST, before any data
30
+ // …
31
+ }
32
+
33
+ export const acceptAppeal = g.field({ // g from the project module
34
+ type: g.object<AppealResult>()({ name: "AcceptAppealPayload", fields: { /* … */ } }),
35
+ args: { appealId: g.arg({ type: g.nonNull(g.ID) }) },
36
+ resolve: (_root, { appealId }, context: Context) => resolveAcceptAppeal({ appealId }, context),
37
+ });
38
+ ```
39
+
40
+ Wire it with `extendGraphqlSchema: g.extend((base) => ({ mutation: { … }, query: { … } }))`; `base.object("Report")` reuses a list's generated type instead of redeclaring one.
41
+
42
+ ## Hard rules
43
+
44
+ 1. **Guard first.** Every custom mutation/query checks session/role before touching data — the list `access` never runs here, so skipping it is an access bypass. Shared guards live in one module (see `keystone-access`).
45
+ 2. **The resolver is a pure exported function**, separate from the `g.field` wrapping it: `(args, context) => payload`. Testable without booting GraphQL, and the unit under test (see `keystone-testing`).
46
+ 3. **`g` comes from the project builder**, never from `@keystone-6/core` — `virtual()` fields excepted.
47
+ 4. **Args are untrusted.** Validate them; never forward a raw `*CreateInput` into a write: the resolver runs through `context.sudo()`, which bypasses field-level access, so unfiltered `data` is mass-assignment. Whitelist what the client may supply.
48
+ 5. **`context.sudo().db` inside the resolver** (see `keystone-models`); errors surface bounded messages, never internals.
49
+
50
+ ## Before declaring the change "done"
51
+
52
+ - `{{qualityGate.fast}}` green.
53
+ - No `g`/`graphql` imported from `@keystone-6/core` outside the shared builder and `virtual()` fields.
54
+ - Every new mutation/query calls its guard before the first read or write.
55
+ - The resolver is exported and unit-tested with a mocked context.
56
+ - No raw client input reaches a `sudo()` write unfiltered.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: prisma-keystone
3
- description: Prisma under Keystone 6 autogenerated schema.prisma (don't edit by hand), migrations via the Prisma CLI, and context.prisma only in scripts. Use when changing the data shape or running migrations.
3
+ description: Prisma under Keystone — generated schema.prisma (don't edit by hand), the Prisma 7 driver adapter and generated client, migrations via the Prisma CLI. Use when changing the data shape, wiring a DB connection or running migrations.
4
4
  type: reference
5
5
  ---
6
6
 
@@ -8,27 +8,35 @@ type: reference
8
8
 
9
9
  ## When to use this skill
10
10
 
11
- When adding/changing a field or a list (changes the DB shape), when running or reviewing migrations, or when writing a seed/backfill script. The classic mistake is editing `schema.prisma` or `schema.graphql` by hand — both are generated artifacts and your change is lost on the next generation.
11
+ When changing the DB shape, running or reviewing migrations, opening a database connection, or writing a seed/backfill script.
12
12
 
13
13
  ## Base rule: the schema is derived, not source
14
14
 
15
15
  - **`schema.prisma` and `schema.graphql` are autogenerated by Keystone** from the lists. The source of truth is the `models/` files. **Never edit them by hand.**
16
- - To change the DB: edit the list (new field, type change, relation), regenerate and migrate. Keystone rewrites the schema.
17
- - Don't read the whole `schema.prisma` to "understand the types" infer them from the list or search with `grep`. It's long and derived.
16
+ - To change the DB: edit the list, regenerate and migrate Keystone rewrites the schema.
17
+ - Don't read all of `schema.prisma` to "understand the types": it's long and derived; `grep` it or read the list.
18
+
19
+ ## Prisma 7: generated client, driver adapter
20
+
21
+ - **Import the client from the generator's `output`, never from `@prisma/client`.** The `generator client` block in `schema.prisma` declares the directory it writes to; that path is the module every import must use — read it, don't guess.
22
+ - **`new PrismaClient()` with no options throws.** Prisma 7 dropped the Rust engine: the client needs a driver adapter (`PrismaPg` from `@prisma/adapter-pg` on Postgres). Every process that talks to the DB (Keystone via `prismaClientOptions`, workers, scripts) needs one: build it once in a shared module, not per entrypoint.
23
+ - **Pool settings moved from the URL to the adapter.** `connection_limit`, `connect_timeout` and `pool_timeout` on `DATABASE_URL` were read by the engine; the `pg` adapter **ignores them silently**. They are pool options now (`max`, `connectionTimeoutMillis`). Set `connectionTimeoutMillis` explicitly: `pg` defaults it to 0 ("wait forever"), turning a database blip into a hung request nothing reclaims.
18
24
 
19
25
  ## Migrations (via the Prisma CLI)
20
26
 
21
27
  ```bash
22
- # Development: generates + applies a migration from the change in the lists
28
+ # dev: generate + apply a migration
23
29
  prisma migrate dev --name <short-description>
24
30
 
25
- # Production / deploy: applies already-generated migrations
31
+ # deploy: apply generated migrations
26
32
  prisma migrate deploy
27
33
  ```
28
34
 
29
- Run the Prisma CLI directly. The `keystone prisma ...` wrapper was **removed in `@keystone-6/core` v8** — its CLI now only accepts `dev`, `build`, `start`, `postinstall` and `telemetry`, so a leftover `keystone prisma migrate` fails with `unknown command`. The wrapper existed to pick up Keystone's database config; from Prisma 7 that config lives in `prisma.config.ts`, which the bare CLI loads on its own.
35
+ Run the Prisma CLI directly: the `keystone prisma ...` wrapper was **removed in `@keystone-6/core` v8**, so a leftover `keystone prisma migrate` fails with `unknown command`. It existed to pick up Keystone's database config; from Prisma 7 that config lives in `prisma.config.ts`, loaded by the bare CLI.
36
+
37
+ Read that file before assuming any CLI input: `migrations.path` says where migrations live (Prisma's `prisma/migrations` default is not a given), `datasource.shadowDatabaseUrl` replaces the `--shadow-database-url` flag `prisma migrate diff` lost, and its side-effect import of the env loader is what loads `.env` — Prisma 7 no longer does.
30
38
 
31
- Review the generated SQL before committing the migration: a destructive migration (dropping a column with data) needs a data plan, not just the schema change.
39
+ Review generated SQL before committing: a destructive migration (dropping a column with data) needs a data plan.
32
40
 
33
41
  ## context.prisma — only in scripts
34
42
 
@@ -37,11 +45,13 @@ context.sudo().db.Model; // app runtime (hooks/services) — see keystone-model
37
45
  context.prisma; // ONLY seed/migration/backfill scripts — never at runtime
38
46
  ```
39
47
 
40
- `context.prisma` gives you the raw Prisma client (without Keystone's access or hooks). It's the right tool for a seed or a bulk backfill, and the wrong tool inside a hook or a resolver — there, always `context.sudo().db`.
48
+ `context.prisma` is the raw client (no Keystone access control or hooks): right for a seed or bulk backfill, wrong in a hook or resolver — there, always `context.sudo().db`.
41
49
 
42
50
  ## Before declaring the change "done"
43
51
 
44
52
  - `{{qualityGate.fast}}` green.
45
- - Neither `schema.prisma` nor `schema.graphql` was edited by hand (they appear only as output of the regeneration).
46
- - Every new migration is committed alongside the list change that originates it.
53
+ - Neither `schema.prisma` nor `schema.graphql` was edited by hand.
54
+ - Every new migration is committed with the list change that originates it.
47
55
  - No `context.prisma` outside `scripts/`.
56
+ - No import from `@prisma/client`: every client import resolves to the generator's `output`.
57
+ - No pool setting as a `DATABASE_URL` query param — only the adapter reads those.
@@ -36,6 +36,11 @@
36
36
  "id": "keystone-rest",
37
37
  "relPath": "presets/bun-keystone/skills/keystone-rest.md",
38
38
  "destRelPath": ".claude/skills/keystone-rest.md"
39
+ },
40
+ {
41
+ "id": "keystone-graphql",
42
+ "relPath": "presets/bun-keystone/skills/keystone-graphql.md",
43
+ "destRelPath": ".claude/skills/keystone-graphql.md"
39
44
  }
40
45
  ],
41
46
  "hooks": []