Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .claude/rules/implementation.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,21 @@ Before writing a new helper, check `#v0/utilities`. Available today:

All helpers carry `/* #__NO_SIDE_EFFECTS__ */` and are tree-shakeable. Module-level allocating constants (e.g., a top-level `new Set([...])`) carry `/* @__PURE__ */` instead — see `utilities/helpers.ts` (the `UNSAFE_KEYS` set) and `utilities/instance.ts` (the `INSTANCE_KEY` literal) for the placement convention. Never add a new utility that introduces a top-level side effect — the barrel cannot absorb it. [PHILOSOPHY §2.7]

## Security primitives — reuse, never reinvent

A headless lib exposes a small, fixed set of injection / DoS sinks, and v0 already ships the guard for each. When you write code in one of these shapes, reach for the existing primitive. Every gap found in the 2026-06-04 security audit was a sibling that missed the guard its twin already had. [user-feedback:2026-06-04]

| When you… | Guard | Has it / missed it |
|-----------|-------|--------------------|
| Build a plain object keyed by caller- or registry-supplied strings | Skip keys in `UNSAFE_KEYS` (`#v0/utilities`) — `__proto__` / `constructor` / `prototype` | `mergeDeep` has it; `usePermissions` didn't |
| Interpolate a value into a CSS string or `<style>` text | Mirror `ThemeAdapter` — validate keys with `SAFE_IDENT`, reject values matching `UNSAFE_CSS` (`useTheme/adapters/adapter.ts`) | v0 `ThemeAdapter` has it; paper `useTheme` didn't |
| Build a `querySelector` string from a runtime id or value | Wrap the dynamic part in `CSS.escape()` | `createCombobox`, `Select` have it |
| Allocate an array from a caller-controlled count (`range(n)`, …) | Bound it — `clamp(Math.floor(n), 0, CAP)`, or the `n > Number.MAX_SAFE_INTEGER → []` guard | `createPagination` has it; `createRating` didn't |

`UNSAFE_KEYS` is importable from `#v0/utilities`; the `ThemeAdapter` CSS sanitizer is a `private static` pattern to mirror, not import. Registry / selection / nested / tokens keyed state is `Map`-based and prototype-pollution-immune by construction — keep it that way; never swap a keyed `Map` for a plain `{}` index.

This is the proactive half. `feedback_bug_family_audit` is the reactive half: after fixing one of these, grep the sibling family for the same shape before calling it done.

## Ticket Pattern (PHILOSOPHY §6.2)

"Tickets" are the currency of every registry. The hierarchy:
Expand Down
6 changes: 5 additions & 1 deletion packages/0/src/composables/createRating/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ import { computed, isRef, shallowRef, toRef, toValue } from 'vue'
import type { ContextTrinity } from '#v0/composables/createTrinity'
import type { ComputedRef, MaybeRefOrGetter, Ref, ShallowRef, WritableComputedRef } from 'vue'

// Upper bound on generated items; guards against an unbounded allocation from a hostile `size`
const MAX_SIZE = 1000

export type RatingItemState = 'full' | 'half' | 'empty'

export interface RatingItemDescriptor {
Expand Down Expand Up @@ -162,8 +165,9 @@ export function createRating<

const items = computed<RatingItemDescriptor[]>(() => {
const current = value.value
const count = clamp(Math.floor(toValue(_size)), 0, MAX_SIZE)

return range(toValue(_size), 1).map(i => ({
return range(count, 1).map(i => ({
value: i,
state: getState(i, current),
}))
Expand Down
6 changes: 6 additions & 0 deletions packages/0/src/composables/usePermissions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ import { V0PermissionsAdapter } from '#v0/composables/usePermissions/adapters'
// Transformers
import { toArray } from '#v0/composables/toArray'

// Utilities
import { UNSAFE_KEYS } from '#v0/utilities'

// Types
import type { TokenContext, TokenOptions, TokenTicket } from '#v0/composables/createTokens'
import type { PermissionsAdapter } from '#v0/composables/usePermissions/adapters'
Expand Down Expand Up @@ -87,10 +90,13 @@ export function createPermissions (_options: PermissionOptions = {}): Permission

const record: Record<string, Record<string, Record<string, boolean | ((context: Record<string, unknown>) => boolean)>>> = {}
for (const role in permissions) {
if (UNSAFE_KEYS.has(role)) continue
if (!record[role]) record[role] = {}
for (const [actions, subjects, condition = true] of permissions[role]!) {
for (const action of toArray(actions)) {
if (UNSAFE_KEYS.has(action)) continue
for (const subject of toArray(subjects)) {
if (UNSAFE_KEYS.has(subject)) continue
if (!record[role][action]) record[role][action] = {}

record[role][action][subject] = condition
Expand Down
2 changes: 1 addition & 1 deletion packages/0/src/composables/useTheme/adapters/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export interface ThemeAdapterSetupContext {
}

export abstract class ThemeAdapter {
private static UNSAFE_CSS = /url\s*\(|@import|expression\s*\(|[{}]/i
private static UNSAFE_CSS = /url\s*\(|@import|expression\s*\(|[{}<>]/i
private static SAFE_IDENT = /^[a-zA-Z0-9_-]+$/

public stylesheetId = 'v0-theme-stylesheet'
Expand Down
2 changes: 1 addition & 1 deletion packages/0/src/utilities/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ export function isNaN (item: unknown): item is number {
}

// Keys that could lead to prototype pollution
const UNSAFE_KEYS = /* @__PURE__ */ new Set(['__proto__', 'constructor', 'prototype'])
export const UNSAFE_KEYS = /* @__PURE__ */ new Set(['__proto__', 'constructor', 'prototype'])

function isPlainObject (value: unknown): value is Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
Expand Down
Loading