diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0c54534 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,58 @@ +name: CI + +on: + pull_request: + push: + branches: [main, develop] + +# Cancel superseded runs on the same ref: only the latest push matters. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + verify: + name: Typecheck, lint, test, build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v7 + with: + node-version: 22 + cache: pnpm + + # Fails loudly if the lockfile has drifted, which is the point of pnpm here. + - run: pnpm install --frozen-lockfile + + - name: Typecheck + run: pnpm exec tsc --noEmit + + - name: Lint + run: pnpm lint + + - name: Test + run: pnpm test + + - name: Build + run: pnpm build + + # The entry bundle is what every visitor pays for on first load. If a + # heavy dependency escapes a lazy route chunk, this is where we find out + # rather than in production. + - name: Check entry bundle budget + run: | + BUDGET=92160 + ENTRY=$(find dist/assets -name 'index-*.js' -print -quit) + SIZE=$(gzip -c "$ENTRY" | wc -c | tr -d ' ') + echo "entry: $ENTRY" + echo "gzipped: ${SIZE}B budget: ${BUDGET}B" + if [ "$SIZE" -gt "$BUDGET" ]; then + echo "::error::Entry bundle ${SIZE}B exceeds the ${BUDGET}B budget." + exit 1 + fi diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..7acabc6 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,73 @@ +name: Deploy + +# Deploys a TAG, never a branch: whatever shipped is always reachable by +# version. Normally started by the Release workflow after it tags a merge to +# main; a hand-pushed tag works too. +on: + push: + tags: ['v*'] + workflow_dispatch: + inputs: + tag: + description: 'Tag to deploy, e.g. v0.2.0' + required: true + type: string + +concurrency: + group: deploy-production + cancel-in-progress: false + +permissions: + contents: read + +jobs: + deploy: + name: Build and deploy to Cloudflare + runs-on: ubuntu-latest + environment: + name: production + url: https://devtools.fadeltd.dev + steps: + - uses: actions/checkout@v7 + with: + # Deploy exactly the tagged commit, whichever way this was started. + ref: ${{ inputs.tag || github.ref }} + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v7 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + # Cloudflare would happily serve a broken build, so nothing ships that + # has not passed the same gate CI applies to pull requests. + - name: Typecheck + run: pnpm exec tsc --noEmit + + - name: Lint + run: pnpm lint + + - name: Test + run: pnpm test + + - name: Build + run: pnpm build + + - name: Deploy + uses: cloudflare/wrangler-action@v4 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + command: deploy + + - name: Smoke check the live site + run: | + sleep 10 + for path in / /id-gen /json /diff /base64 /list /text-stats; do + code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 "https://devtools.fadeltd.dev${path}") + echo "${path} -> ${code}" + [ "${code}" = "200" ] || { echo "::error::${path} returned ${code}"; exit 1; } + done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9ce0032 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,88 @@ +name: Release + +# Merging to main does not deploy directly. It derives the next version from +# the [Unreleased] section of CHANGELOG.md, rewrites the changelog, commits +# that back to main, and pushes a tag. The TAG is what deploys. +# +# A merge with an empty [Unreleased] releases nothing, so merging and releasing +# stay separate decisions. +on: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: release + cancel-in-progress: false + +permissions: + contents: write + actions: write + +jobs: + release: + name: Cut a release from the changelog + runs-on: ubuntu-latest + # Never react to our own release commit. + if: "!contains(github.event.head_commit.message, '[skip ci]')" + env: + # A tag pushed with GITHUB_TOKEN does NOT trigger other workflows, by + # design, to prevent recursion. With a PAT in RELEASE_TOKEN the tag + # triggers Deploy natively; without one we start Deploy explicitly. + HAS_PAT: ${{ secrets.RELEASE_TOKEN != '' }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + token: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }} + + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v7 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + + - id: cut + name: Derive the version from CHANGELOG.md + run: pnpm exec vite-node scripts/release.ts + + - name: Commit the changelog and tag + if: steps.cut.outputs.released == 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add CHANGELOG.md package.json + # [skip ci] so this push cannot re-enter this workflow when a PAT is + # in use -- with a PAT, pushes DO trigger workflows. + git commit -m "Release ${{ steps.cut.outputs.tag }} [skip ci]" + git tag -a "${{ steps.cut.outputs.tag }}" -m "${{ steps.cut.outputs.tag }}" + git push origin HEAD:main + git push origin "${{ steps.cut.outputs.tag }}" + + - name: Create the GitHub release + if: steps.cut.outputs.released == 'true' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "${{ steps.cut.outputs.tag }}" \ + --title "${{ steps.cut.outputs.tag }}" \ + --notes "${{ steps.cut.outputs.notes }}" + + # Only needed while no PAT is configured; with one, the tag push above + # has already started Deploy. + - name: Start Deploy (no PAT configured) + if: steps.cut.outputs.released == 'true' && env.HAS_PAT != 'true' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + echo "::notice::No RELEASE_TOKEN set, so the tag cannot trigger Deploy. Dispatching it directly." + gh workflow run deploy.yml --ref main -f tag="${{ steps.cut.outputs.tag }}" + + - name: Summary + run: | + if [ "${{ steps.cut.outputs.released }}" = "true" ]; then + echo "Released ${{ steps.cut.outputs.tag }}" >> "$GITHUB_STEP_SUMMARY" + else + echo "No [Unreleased] entries — nothing released." >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 4754f04..39aba8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,37 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] -Nothing yet. +### Added +- **ID Generator** — prefixed identifiers in the Stripe style + (`sk_live_` plus 24 random characters): set a prefix, separator, length, + alphabet and batch size. Doubles as a password generator via a symbol + alphabet and an + option to drop confusable glyphs (`0`/`O`, `1`/`l`/`I`). + - Live entropy readout with a qualitative strength verdict. Deliberately no + "time to crack" figure: that depends entirely on assumed hardware and on + whether the value is hashed, so quoting one would be false precision. + - Presets for Stripe secret and test keys, object ids, API tokens, hex + session ids, passwords and human-readable codes. + - Values come from `crypto.getRandomValues` with rejection sampling, never + `value % n`, which biases toward the start of the alphabet. + - Settings persist; **generated values never do**. +- Collapsible sidebar, kept as an icon rail with accessible names intact. +- Link to the GitHub repository in the header. +- Continuous integration on every pull request, including an entry-bundle size + budget so a heavy dependency escaping a lazy chunk fails the build rather + than reaching production. +- Release automation: merging to `main` creates a version tag, and the **tag** + is what deploys. A merge that does not change the version in `package.json` + releases nothing, so merging and releasing are separate decisions. + +### Fixed +- Focus rings on full-bleed text areas were drawn outside the element and + clipped by the surrounding pane, so only the top and right edges were + visible. They are now drawn inset. + +### Changed +- Unbiased randomness primitives moved to `src/lib/random.ts`, now shared by + the list shuffler and the ID generator. ## [0.1.0] — 2026-09-24 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e21838c..e7baee7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,6 +70,49 @@ Small, focused PRs. If you are planning something large, open an issue first — the project has a deliberate list of things it will not do, and it would be a shame for you to build one of them. +## Releases + +**You never pick a version number, and you never write a release date.** + +While you work, add your entry under `## [Unreleased]` in `CHANGELOG.md`, using +a Keep a Changelog heading: + +```markdown +## [Unreleased] + +### Added +- The thing you added +``` + +Merging to `main` then does the rest, automatically: + +1. Reads `[Unreleased]` and derives the semver bump from its headings — + `### Breaking` → major, `### Added` → minor, anything else + (`Fixed`, `Changed`, `Security`, `Removed`) → patch. +2. Rewrites `CHANGELOG.md`, moving `[Unreleased]` into a dated + `## [x.y.z]` section and leaving a fresh empty `[Unreleased]` behind. +3. Bumps `version` in `package.json`, commits that back to `main`, and pushes + the tag `vx.y.z`. +4. **The tag** triggers the deploy to Cloudflare. Merging alone never deploys. + +An empty `[Unreleased]` releases nothing, so a docs-only or refactor merge ships +nothing. That is deliberate: merging and releasing are separate decisions. + +Two notes for anyone editing the workflows: + +- `### Removed` is deliberately a *patch*, not a major. Inferring a major bump + from a tidy-up would let a cleanup silently become a 1.0. Major requires an + explicit `### Breaking` heading. +- Below 1.0, a breaking change bumps the minor (`0.4.2` → `0.5.0`), per semver + convention for pre-stable projects. + +The bump logic lives in `scripts/changelog.ts` and is unit-tested, because a +mistake there silently ships the wrong version. Preview what a merge would do: + +```bash +pnpm exec vite-node scripts/release.ts --dry-run +``` + ## Licence By contributing you agree your contributions are licensed under the MIT diff --git a/scripts/changelog.test.ts b/scripts/changelog.test.ts new file mode 100644 index 0000000..6f450ca --- /dev/null +++ b/scripts/changelog.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest' +import { + applyRelease, + determineBump, + nextVersion, + parseUnreleased, + parseVersion, +} from './changelog' + +const doc = (unreleased: string, rest = '## [0.1.0] — 2026-01-01\n\n### Added\n- first\n') => + `# Changelog\n\nBlurb.\n\n## [Unreleased]\n\n${unreleased}\n\n${rest}` + +describe('parseUnreleased', () => { + it('finds sections and bullets', () => { + const u = parseUnreleased(doc('### Added\n- a thing\n\n### Fixed\n- a bug')) + expect(u.sections).toEqual(['Added', 'Fixed']) + expect(u.hasContent).toBe(true) + }) + + it('treats filler as no content', () => { + expect(parseUnreleased(doc('Nothing yet.')).hasContent).toBe(false) + }) + + it('treats a heading with no bullets as no content', () => { + expect(parseUnreleased(doc('### Added')).hasContent).toBe(false) + }) + + it('does not run past the next release heading', () => { + const u = parseUnreleased(doc('### Fixed\n- mine')) + expect(u.body).not.toContain('first') + expect(u.sections).toEqual(['Fixed']) + }) + + it('handles a changelog with no Unreleased section', () => { + const u = parseUnreleased('# Changelog\n\n## [0.1.0] — 2026-01-01\n') + expect(u.hasContent).toBe(false) + }) +}) + +const bumpOf = (body: string) => determineBump(parseUnreleased(doc(body))) + +describe('determineBump', () => { + it('is none when there is nothing to release', () => { + expect(bumpOf('Nothing yet.')).toBe('none') + }) + + it('is minor for Added', () => { + expect(bumpOf('### Added\n- feature')).toBe('minor') + }) + + it('is patch for Fixed, Changed or Security alone', () => { + expect(bumpOf('### Fixed\n- bug')).toBe('patch') + expect(bumpOf('### Changed\n- tweak')).toBe('patch') + expect(bumpOf('### Security\n- hardening')).toBe('patch') + }) + + it('is major only when Breaking is explicit', () => { + expect(bumpOf('### Breaking\n- dropped an API')).toBe('major') + // Removed must NOT imply major: a tidy-up should not force a 1.0. + expect(bumpOf('### Removed\n- dead code')).toBe('patch') + }) + + it('takes the highest applicable bump', () => { + expect(bumpOf('### Fixed\n- bug\n\n### Added\n- feature')).toBe('minor') + expect(bumpOf('### Added\n- feature\n\n### Breaking\n- gone')).toBe('major') + }) +}) + +describe('nextVersion', () => { + it('bumps each component', () => { + expect(nextVersion('1.2.3', 'patch')).toBe('1.2.4') + expect(nextVersion('1.2.3', 'minor')).toBe('1.3.0') + expect(nextVersion('1.2.3', 'major')).toBe('2.0.0') + }) + + it('keeps 0.x pre-stable: a breaking change is a minor bump', () => { + expect(nextVersion('0.4.2', 'major')).toBe('0.5.0') + }) + + it('returns the current version for no bump', () => { + expect(nextVersion('1.2.3', 'none')).toBe('1.2.3') + }) + + it('rejects non-semver input rather than guessing', () => { + expect(() => parseVersion('1.2')).toThrow() + expect(() => parseVersion('v1.2.3')).toThrow() + }) +}) + +describe('applyRelease', () => { + const today = '2026-09-24' + + it('returns null when there is nothing to release', () => { + expect(applyRelease(doc('Nothing yet.'), '0.1.0', today)).toBeNull() + }) + + it('moves Unreleased into a dated section and computes the version', () => { + const r = applyRelease(doc('### Added\n- a feature'), '0.1.0', today) + expect(r).not.toBeNull() + expect(r!.version).toBe('0.2.0') + expect(r!.bump).toBe('minor') + expect(r!.changelog).toContain(`## [0.2.0] — ${today}`) + expect(r!.changelog).toContain('- a feature') + }) + + it('leaves a fresh empty Unreleased behind', () => { + const r = applyRelease(doc('### Fixed\n- bug'), '0.1.0', today)! + expect(parseUnreleased(r.changelog).hasContent).toBe(false) + expect(r.changelog).toMatch(/## \[Unreleased\]\s*\n\s*Nothing yet\./) + }) + + it('preserves earlier releases', () => { + const r = applyRelease(doc('### Fixed\n- bug'), '0.1.0', today)! + expect(r.changelog).toContain('## [0.1.0] — 2026-01-01') + expect(r.changelog).toContain('- first') + }) + + it('orders the new release above the previous one', () => { + const r = applyRelease(doc('### Added\n- x'), '0.1.0', today)! + expect(r.changelog.indexOf('## [0.2.0]')).toBeLessThan(r.changelog.indexOf('## [0.1.0]')) + }) + + it('returns the notes for the release body', () => { + const r = applyRelease(doc('### Added\n- a feature'), '0.1.0', today)! + expect(r.notes).toContain('- a feature') + expect(r.notes).not.toContain('first') + }) + + it('is idempotent: running it again releases nothing', () => { + const first = applyRelease(doc('### Added\n- x'), '0.1.0', today)! + expect(applyRelease(first.changelog, first.version, today)).toBeNull() + }) + + it('does not leave runs of blank lines', () => { + const r = applyRelease(doc('### Added\n- x'), '0.1.0', today)! + expect(r.changelog).not.toMatch(/\n{4,}/) + }) +}) diff --git a/scripts/changelog.ts b/scripts/changelog.ts new file mode 100644 index 0000000..041b912 --- /dev/null +++ b/scripts/changelog.ts @@ -0,0 +1,119 @@ +/** + * Release logic, derived from CHANGELOG.md. + * + * Pure and tested, because a mistake here silently ships the wrong version or + * loses release notes, and neither is obvious from a green workflow run. + */ + +export type Bump = 'major' | 'minor' | 'patch' | 'none' + +export interface Unreleased { + /** The section bodies under `## [Unreleased]`, verbatim. */ + body: string + /** Heading names found, e.g. ['Added', 'Fixed']. */ + sections: string[] + hasContent: boolean +} + +const UNRELEASED_HEADING = /^## \[Unreleased\]\s*$/m +const ANY_RELEASE_HEADING = /^## \[\d+\.\d+\.\d+\]/m + +/** Extract the Unreleased block, ignoring filler like "Nothing yet." */ +export function parseUnreleased(changelog: string): Unreleased { + const start = changelog.search(UNRELEASED_HEADING) + if (start === -1) return { body: '', sections: [], hasContent: false } + + const afterHeading = changelog.indexOf('\n', start) + 1 + const rest = changelog.slice(afterHeading) + const nextRelease = rest.search(ANY_RELEASE_HEADING) + const body = (nextRelease === -1 ? rest : rest.slice(0, nextRelease)).trim() + + const sections = [...body.matchAll(/^### (.+?)\s*$/gm)].map((m) => m[1]!.trim()) + + // A section heading with no bullets under it is not content. + const hasBullets = /^[-*] /m.test(body) + + return { body, sections, hasContent: sections.length > 0 && hasBullets } +} + +/** + * Map Keep a Changelog sections to a semver bump. + * + * `Breaking` must be explicit — inferring a major from `Removed` would let a + * tidy-up silently become a 1.0, which is exactly the surprise this should + * avoid. + */ +export function determineBump(u: Unreleased): Bump { + if (!u.hasContent) return 'none' + const headings = new Set(u.sections.map((s) => s.toLowerCase())) + if (headings.has('breaking') || headings.has('breaking changes')) return 'major' + if (headings.has('added')) return 'minor' + return 'patch' +} + +export function parseVersion(version: string): [number, number, number] { + const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(version.trim()) + if (!m) throw new Error(`Not a semver version: "${version}"`) + return [Number(m[1]), Number(m[2]), Number(m[3])] +} + +export function nextVersion(current: string, bump: Bump): string { + const [major, minor, patch] = parseVersion(current) + switch (bump) { + case 'major': + // Stay in 0.x until the author decides to commit to a stable API: + // a breaking change pre-1.0 is a minor bump by convention. + return major === 0 ? `0.${minor + 1}.0` : `${major + 1}.0.0` + case 'minor': + return `${major}.${minor + 1}.0` + case 'patch': + return `${major}.${minor}.${patch + 1}` + case 'none': + return current + } +} + +export interface ReleaseResult { + changelog: string + version: string + bump: Bump + notes: string +} + +/** + * Move the Unreleased block into a dated release section and leave a fresh + * empty Unreleased behind. Returns the new file contents; writes nothing. + */ +export function applyRelease( + changelog: string, + currentVersion: string, + today: string, +): ReleaseResult | null { + const unreleased = parseUnreleased(changelog) + const bump = determineBump(unreleased) + if (bump === 'none') return null + + const version = nextVersion(currentVersion, bump) + + const start = changelog.search(UNRELEASED_HEADING) + const afterHeading = changelog.indexOf('\n', start) + 1 + const head = changelog.slice(0, afterHeading) + const rest = changelog.slice(afterHeading) + const nextRelease = rest.search(ANY_RELEASE_HEADING) + const tail = nextRelease === -1 ? '' : rest.slice(nextRelease) + + const rebuilt = + head + + '\nNothing yet.\n\n' + + `## [${version}] — ${today}\n\n` + + unreleased.body + + '\n\n' + + tail + + return { + changelog: rebuilt.replace(/\n{4,}/g, '\n\n\n').trimEnd() + '\n', + version, + bump, + notes: unreleased.body, + } +} diff --git a/scripts/release.ts b/scripts/release.ts new file mode 100644 index 0000000..38604b6 --- /dev/null +++ b/scripts/release.ts @@ -0,0 +1,60 @@ +/** + * Cut a release from CHANGELOG.md. + * + * Reads the Unreleased section, decides the semver bump from its headings, + * rewrites CHANGELOG.md and package.json, and prints the outcome for the + * workflow to consume. Writes files but never touches git — the workflow owns + * committing, tagging and pushing, so this stays runnable locally as a dry run. + * + * pnpm exec vite-node scripts/release.ts --dry-run + */ +import { appendFileSync, readFileSync, writeFileSync } from 'node:fs' +import { applyRelease } from './changelog' + +const dryRun = process.argv.includes('--dry-run') + +const changelogPath = 'CHANGELOG.md' +const packagePath = 'package.json' + +const changelog = readFileSync(changelogPath, 'utf8') +const pkg = JSON.parse(readFileSync(packagePath, 'utf8')) as { version: string } +const today = new Date().toISOString().slice(0, 10) + +const result = applyRelease(changelog, pkg.version, today) + +function emit(key: string, value: string): void { + const out = process.env.GITHUB_OUTPUT + if (out) appendFileSync(out, `${key}=${value}\n`) +} + +if (!result) { + console.log('Nothing under [Unreleased] — no release.') + emit('released', 'false') + process.exit(0) +} + +console.log(`bump : ${result.bump}`) +console.log(`version : ${pkg.version} -> ${result.version}`) +console.log(`tag : v${result.version}`) + +if (dryRun) { + console.log('\n--- notes ---') + console.log(result.notes) + console.log('\n(dry run: no files written)') + process.exit(0) +} + +writeFileSync(changelogPath, result.changelog) +writeFileSync(packagePath, JSON.stringify({ ...pkg, version: result.version }, null, 2) + '\n') + +emit('released', 'true') +emit('version', result.version) +emit('tag', `v${result.version}`) + +// Release notes can be multi-line, so they need the delimiter form. +const out = process.env.GITHUB_OUTPUT +if (out) { + appendFileSync(out, `notes< import('@/tools/id-gen/IdGenTool'), + status: 'beta', + }, { slug: 'json', title: 'JSON Formatter', diff --git a/src/tools/id-gen/IdGenTool.tsx b/src/tools/id-gen/IdGenTool.tsx new file mode 100644 index 0000000..efee17d --- /dev/null +++ b/src/tools/id-gen/IdGenTool.tsx @@ -0,0 +1,275 @@ +import { useCallback, useState } from 'react' +import { RefreshCw, ShieldCheck } from 'lucide-react' +import { ToolFrame } from '@/components/layout/ToolFrame' +import { Badge } from '@/components/ui/Badge' +import { Button } from '@/components/ui/Button' +import { CodeArea } from '@/components/ui/CodeArea' +import { CopyButton } from '@/components/ui/CopyButton' +import { Select, Toggle } from '@/components/ui/Select' +import { useToolState } from '@/lib/persist/useToolState' +import { useShortcuts } from '@/lib/keys/useShortcuts' +import { useToolUsageTracker } from '@/lib/prefs' +import { copyText } from '@/lib/util/clipboard' +import { formatCount } from '@/lib/util/bytes' +import { + DEFAULT_OPTIONS, + MAX_COUNT, + MAX_LENGTH, + PRESETS, + generateIds, + strength, + type AlphabetName, + type IdOptions, +} from './core/generate' + +const ALPHABET_LABELS: Record = { + alphanumeric: 'Alphanumeric (A–Z a–z 0–9)', + lowercase: 'Lowercase + digits', + uppercase: 'Uppercase + digits', + letters: 'Letters only', + numeric: 'Digits only', + hex: 'Hexadecimal', + base58: 'Base58 (no 0 O I l)', + password: 'Alphanumeric + symbols', + custom: 'Custom…', +} + +const ERRORS: Record = { + 'empty-alphabet': 'The alphabet is empty. Add characters, or turn off “exclude ambiguous”.', + 'invalid-length': `Length must be a whole number between 1 and ${MAX_LENGTH}.`, + 'invalid-count': `Count must be a whole number between 1 and ${MAX_COUNT}.`, +} + +export default function IdGenTool() { + useToolUsageTracker('id-gen') + + // Only the OPTIONS persist. Generated values are secrets and live in + // component state, so they are never written to disk under any code path. + const { state, setState, reset } = useToolState('id-gen', DEFAULT_OPTIONS) + + // Generating is an action, not a derivation -- both changing an option and + // pressing Generate are user events, so the result is state updated from + // those handlers rather than a memo with an artificial dependency. + const [result, setResult] = useState(() => generateIds(state)) + + const regenerate = useCallback(() => setResult(generateIds(state)), [state]) + + const applyOptions = useCallback( + (update: (prev: IdOptions) => IdOptions) => { + setState((prev) => { + const next = update(prev) + setResult(generateIds(next)) + return next + }) + }, + [setState], + ) + + const ids = result.ids + const output = ids.join('\n') + const verdict = strength(result.entropyBits) + + useShortcuts([ + { + combo: 'mod+enter', + label: 'Generate again', + group: 'ID generator', + scope: 'tool', + whileTyping: true, + run: regenerate, + }, + { + combo: 'mod+shift+c', + label: 'Copy all', + group: 'ID generator', + scope: 'tool', + whileTyping: true, + run: () => void copyText(output), + }, + ]) + + const set = (key: K, value: IdOptions[K]) => + applyOptions((p) => ({ ...p, [key]: value })) + + const field = 'min-h-11 w-full rounded-[4px] border border-border bg-bg px-2 text-fg md:min-h-7' + + return ( + + + + + + + } + > +
+
+
+ + + + + + + + + {state.alphabet === 'custom' && ( + + )} + + + +
+ set('excludeAmbiguous', v)} + > + No 0/O, 1/l/I + +
+
+ +
+
+ Entropy + + {result.entropyBits.toFixed(0)} bits + +
+
+ + + + + {verdict.label} + +
+

{verdict.detail}

+

+ Drawn from {formatCount(result.alphabetSize)} characters. The prefix adds no + entropy — it is public by design. +

+
+ +

+ + Generated with crypto.getRandomValues and unbiased + sampling. Your settings are saved; the generated values never are. +

+
+ +
+ {result.error !== undefined ? ( +
+ {ERRORS[result.error] ?? 'Invalid options.'} +
+ ) : ( + + )} +
+ {formatCount(ids.length)} generated + {ids[0] !== undefined && {ids[0].length} characters each} +
+
+
+
+ ) +} diff --git a/src/tools/id-gen/core/generate.test.ts b/src/tools/id-gen/core/generate.test.ts new file mode 100644 index 0000000..ed92fa4 --- /dev/null +++ b/src/tools/id-gen/core/generate.test.ts @@ -0,0 +1,234 @@ +import fc from 'fast-check' +import { describe, expect, it } from 'vitest' +import { mulberry32 } from '@/lib/random' +import { + ALPHABETS, + DEFAULT_OPTIONS, + MAX_COUNT, + MAX_LENGTH, + PRESETS, + entropyBits, + generateIds, + resolveAlphabet, + strength, + type IdOptions, +} from './generate' + +const opts = (over: Partial = {}): IdOptions => ({ ...DEFAULT_OPTIONS, ...over }) + +describe('resolveAlphabet', () => { + it('returns the named alphabet', () => { + expect(resolveAlphabet({ alphabet: 'hex', excludeAmbiguous: false })).toBe('0123456789abcdef') + }) + + it('drops ambiguous glyphs when asked', () => { + const a = resolveAlphabet({ alphabet: 'alphanumeric', excludeAmbiguous: true }) + for (const ch of ['O', '0', 'o', 'I', 'l', '1']) expect(a).not.toContain(ch) + expect(a).toContain('A') + }) + + it('deduplicates, so a repeated character cannot skew the distribution', () => { + const a = resolveAlphabet({ alphabet: 'custom', customAlphabet: 'aaabbc', excludeAmbiguous: false }) + expect(a).toBe('abc') + }) + + it('base58 already excludes the confusable glyphs', () => { + for (const ch of ['0', 'O', 'I', 'l']) expect(ALPHABETS.base58).not.toContain(ch) + }) +}) + +describe('generateIds', () => { + it('produces the requested count', () => { + expect(generateIds(opts({ count: 7 })).ids).toHaveLength(7) + }) + + it('builds the Stripe shape', () => { + const ids = generateIds(opts({ prefix: 'sk_live', separator: '_', length: 24, count: 1 })).ids + expect(ids[0]).toMatch(/^sk_live_[A-Za-z0-9]{24}$/) + }) + + it('omits the separator when there is no prefix', () => { + const ids = generateIds(opts({ prefix: '', separator: '_', length: 8, count: 1 })).ids + expect(ids[0]).toMatch(/^[A-Za-z0-9]{8}$/) + }) + + it('measures length over the random portion only', () => { + const ids = generateIds(opts({ prefix: 'cus', separator: '_', length: 10, count: 1 })).ids + expect(ids[0]?.slice('cus_'.length)).toHaveLength(10) + }) + + it('only ever emits characters from the alphabet', () => { + const alphabet = resolveAlphabet({ alphabet: 'hex', excludeAmbiguous: false }) + for (const id of generateIds(opts({ prefix: '', separator: '', alphabet: 'hex', count: 50 })).ids) { + for (const ch of id) expect(alphabet).toContain(ch) + } + }) + + it('does not repeat itself across a batch', () => { + const { ids } = generateIds(opts({ count: 200, length: 24 })) + expect(new Set(ids).size).toBe(200) + }) + + it('is reproducible when given a seeded source', () => { + const a = generateIds(opts({ count: 5 }), mulberry32(42)).ids + const b = generateIds(opts({ count: 5 }), mulberry32(42)).ids + const c = generateIds(opts({ count: 5 }), mulberry32(43)).ids + expect(a).toEqual(b) + expect(a).not.toEqual(c) + }) + + it('does not throw on a large batch', () => { + // crypto.getRandomValues throws above 64 KiB per call; the chunked source + // is what keeps this safe. + expect(() => generateIds(opts({ count: MAX_COUNT, length: 64 }))).not.toThrow() + }) + + describe('validation', () => { + it('rejects an empty alphabet rather than producing empty ids', () => { + const r = generateIds(opts({ alphabet: 'custom', customAlphabet: '' })) + expect(r.error).toBe('empty-alphabet') + expect(r.ids).toEqual([]) + }) + + it('rejects an alphabet emptied by the ambiguity filter', () => { + expect( + generateIds(opts({ alphabet: 'custom', customAlphabet: '0O1l', excludeAmbiguous: true })) + .error, + ).toBe('empty-alphabet') + }) + + it('rejects out-of-range lengths and counts', () => { + expect(generateIds(opts({ length: 0 })).error).toBe('invalid-length') + expect(generateIds(opts({ length: MAX_LENGTH + 1 })).error).toBe('invalid-length') + expect(generateIds(opts({ length: 1.5 })).error).toBe('invalid-length') + expect(generateIds(opts({ count: 0 })).error).toBe('invalid-count') + expect(generateIds(opts({ count: MAX_COUNT + 1 })).error).toBe('invalid-count') + }) + }) +}) + +describe('entropyBits', () => { + it('is length times log2(alphabet size)', () => { + expect(entropyBits(16, 32)).toBe(128) // hex: 4 bits per char + expect(entropyBits(64, 10)).toBe(60) + }) + + it('is zero for a degenerate alphabet', () => { + expect(entropyBits(1, 100)).toBe(0) + expect(entropyBits(62, 0)).toBe(0) + }) + + it('matches the value reported by generateIds', () => { + const r = generateIds(opts({ alphabet: 'hex', length: 32, prefix: '', separator: '' })) + expect(r.entropyBits).toBe(128) + expect(r.alphabetSize).toBe(16) + }) + + it('counts only the random portion — the prefix is public', () => { + const withPrefix = generateIds(opts({ prefix: 'sk_live', length: 24 })) + const without = generateIds(opts({ prefix: '', separator: '', length: 24 })) + expect(withPrefix.entropyBits).toBe(without.entropyBits) + }) +}) + +describe('strength', () => { + it('bands entropy sensibly', () => { + expect(strength(40).level).toBe('weak') + expect(strength(70).level).toBe('fair') + expect(strength(128).level).toBe('strong') + expect(strength(400).level).toBe('excessive') + }) + + it('always explains itself', () => { + for (const bits of [0, 63, 64, 79, 80, 256, 257, 1000]) { + expect(strength(bits).detail.length).toBeGreaterThan(10) + } + }) + + it('rates the Stripe default as strong', () => { + // 24 alphanumeric characters is ~143 bits. + expect(strength(generateIds(DEFAULT_OPTIONS).entropyBits).level).toBe('strong') + }) +}) + +describe('presets', () => { + it('all produce valid output', () => { + for (const preset of PRESETS) { + const r = generateIds(opts(preset.options)) + expect(r.error, preset.name).toBeUndefined() + expect(r.ids.length, preset.name).toBeGreaterThan(0) + } + }) + + it('the password preset is strong and avoids confusable glyphs', () => { + const preset = PRESETS.find((p) => p.name === 'Password')! + const r = generateIds(opts(preset.options)) + expect(strength(r.entropyBits).level).toBe('strong') + for (const id of r.ids) { + for (const ch of ['O', '0', 'l', '1', 'I']) expect(id).not.toContain(ch) + } + }) +}) + +describe('properties', () => { + it('every id has exactly prefix + separator + length characters', () => { + fc.assert( + fc.property( + fc.stringMatching(/^[a-z_]{0,8}$/), + fc.integer({ min: 1, max: 40 }), + fc.integer({ min: 1, max: 5 }), + (prefix, length, count) => { + const r = generateIds(opts({ prefix, separator: '_', length, count })) + const head = prefix === '' ? 0 : prefix.length + 1 + for (const id of r.ids) expect(id).toHaveLength(head + length) + }, + ), + { numRuns: 150 }, + ) + }) + + it('never emits a character outside the resolved alphabet', () => { + fc.assert( + fc.property( + fc.constantFrom('alphanumeric', 'hex', 'base58', 'numeric', 'password'), + fc.boolean(), + (alphabet, excludeAmbiguous) => { + const o = opts({ + alphabet: alphabet as IdOptions['alphabet'], + excludeAmbiguous, + prefix: '', + separator: '', + count: 5, + length: 32, + }) + const allowed = new Set(resolveAlphabet(o)) + for (const id of generateIds(o).ids) { + for (const ch of id) expect(allowed.has(ch)).toBe(true) + } + }, + ), + { numRuns: 100 }, + ) + }) + + it('distribution is not visibly biased toward the start of the alphabet', () => { + // A modulo-based implementation skews toward early characters. With 16 + // symbols and 16k draws, every symbol should land near 1/16 of the time. + const { ids } = generateIds( + opts({ alphabet: 'hex', prefix: '', separator: '', length: 256, count: 64 }), + ) + const counts = new Map() + let total = 0 + for (const id of ids) { + for (const ch of id) { + counts.set(ch, (counts.get(ch) ?? 0) + 1) + total++ + } + } + const expected = total / 16 + for (const [, n] of counts) { + expect(Math.abs(n - expected) / expected).toBeLessThan(0.2) + } + expect(counts.size).toBe(16) + }) +}) diff --git a/src/tools/id-gen/core/generate.ts b/src/tools/id-gen/core/generate.ts new file mode 100644 index 0000000..cb63ae4 --- /dev/null +++ b/src/tools/id-gen/core/generate.ts @@ -0,0 +1,219 @@ +import { cryptoRandomSource, unbiasedBelow, type RandomSource } from '@/lib/random' + +/** + * Prefixed identifier generation, in the shape Stripe popularised: + * a readable prefix, a separator, then a run of random characters + * (`sk_live_` followed by 24 alphanumerics). + * + * The example above is written out rather than shown as a literal on purpose: + * a realistic-looking key in source trips secret scanners, which cannot tell a + * documentation example from a real leak -- and should not have to. + * + * A readable prefix carries the environment and the object type, so an id is + * self-describing in a log line, and a leaked key is identifiable on sight. + */ + +export const ALPHABETS = { + alphanumeric: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789', + lowercase: 'abcdefghijklmnopqrstuvwxyz0123456789', + uppercase: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', + letters: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', + numeric: '0123456789', + hex: '0123456789abcdef', + /** Bitcoin base58: alphanumeric minus 0, O, I and l. */ + base58: '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz', + /** Adds symbols, for password use. */ + password: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+[]{};:,.?', +} as const + +export type AlphabetName = keyof typeof ALPHABETS + +/** + * Glyphs that are easily confused when read aloud or transcribed by hand. + * Worth excluding for anything a human will retype; pointless for a machine + * token, which is why it is optional. + */ +const AMBIGUOUS = new Set('O0oIl1|`\'"~,.;:') + +export interface IdOptions { + prefix: string + separator: string + /** Length of the random portion only, excluding prefix and separator. */ + length: number + alphabet: AlphabetName | 'custom' + customAlphabet?: string + excludeAmbiguous: boolean + count: number +} + +export const DEFAULT_OPTIONS: IdOptions = { + prefix: 'sk_live', + separator: '_', + length: 24, + alphabet: 'alphanumeric', + excludeAmbiguous: false, + count: 10, +} + +/** Deduplicated so a repeated character cannot skew the distribution. */ +export function resolveAlphabet(options: Pick): string { + const base = + options.alphabet === 'custom' + ? (options.customAlphabet ?? '') + : ALPHABETS[options.alphabet] + + const seen = new Set() + let out = '' + for (const ch of base) { + if (seen.has(ch)) continue + if (options.excludeAmbiguous && AMBIGUOUS.has(ch)) continue + seen.add(ch) + out += ch + } + return out +} + +export type GenerateError = 'empty-alphabet' | 'invalid-length' | 'invalid-count' + +export interface GenerateResult { + ids: string[] + alphabetSize: number + /** Entropy of the random portion. The prefix contributes none -- it is public. */ + entropyBits: number + error?: GenerateError +} + +export const MAX_COUNT = 1000 +export const MAX_LENGTH = 512 + +export function generateIds(options: IdOptions, source?: RandomSource): GenerateResult { + const alphabet = resolveAlphabet(options) + const alphabetSize = alphabet.length + + if (alphabetSize === 0) { + return { ids: [], alphabetSize: 0, entropyBits: 0, error: 'empty-alphabet' } + } + if (!Number.isInteger(options.length) || options.length < 1 || options.length > MAX_LENGTH) { + return { ids: [], alphabetSize, entropyBits: 0, error: 'invalid-length' } + } + if (!Number.isInteger(options.count) || options.count < 1 || options.count > MAX_COUNT) { + return { ids: [], alphabetSize, entropyBits: 0, error: 'invalid-count' } + } + + const next = source ?? cryptoRandomSource() + const chars = [...alphabet] + const head = options.prefix === '' ? '' : options.prefix + options.separator + + const ids: string[] = [] + for (let n = 0; n < options.count; n++) { + let body = '' + for (let i = 0; i < options.length; i++) { + // Rejection sampling, not modulo: `value % size` biases toward the start + // of the alphabet whenever size does not divide 2^32. + body += chars[unbiasedBelow(next, chars.length)] + } + ids.push(head + body) + } + + return { ids, alphabetSize, entropyBits: entropyBits(alphabetSize, options.length) } +} + +export function entropyBits(alphabetSize: number, length: number): number { + if (alphabetSize <= 1 || length <= 0) return 0 + return Math.log2(alphabetSize) * length +} + +export type Strength = 'weak' | 'fair' | 'strong' | 'excessive' + +export interface StrengthVerdict { + level: Strength + label: string + detail: string +} + +/** + * Qualitative strength from entropy. + * + * Deliberately not a "time to crack" figure: that number depends entirely on + * assumed hardware and on whether the value is hashed and how, so quoting one + * would be false precision dressed up as a guarantee. + */ +export function strength(bits: number): StrengthVerdict { + if (bits < 64) { + return { + level: 'weak', + label: 'Weak', + detail: 'Fine for a non-secret identifier, but not for anything that authenticates.', + } + } + if (bits < 80) { + return { + level: 'fair', + label: 'Fair', + detail: 'Acceptable for a short-lived token. Prefer more for a long-lived secret.', + } + } + if (bits <= 256) { + return { + level: 'strong', + label: 'Strong', + detail: 'Comfortably beyond brute force for a credential of this kind.', + } + } + return { + level: 'excessive', + label: 'Very high', + detail: 'Far beyond any practical need. Extra length costs storage and legibility.', + } +} + +export interface Preset { + name: string + description: string + options: Partial +} + +export const PRESETS: readonly Preset[] = [ + { + name: 'Stripe secret key', + description: 'sk_live_ plus 24 alphanumeric characters', + options: { prefix: 'sk_live', separator: '_', length: 24, alphabet: 'alphanumeric' }, + }, + { + name: 'Stripe test key', + description: 'sk_test_ plus 24 alphanumeric characters', + options: { prefix: 'sk_test', separator: '_', length: 24, alphabet: 'alphanumeric' }, + }, + { + name: 'Object id', + description: 'A short prefixed id, e.g. cus_ or evt_', + options: { prefix: 'cus', separator: '_', length: 14, alphabet: 'alphanumeric' }, + }, + { + name: 'API token', + description: '40 alphanumeric characters, no prefix', + options: { prefix: '', separator: '', length: 40, alphabet: 'alphanumeric' }, + }, + { + name: 'Hex token', + description: '32 hex characters, like a session id', + options: { prefix: '', separator: '', length: 32, alphabet: 'hex' }, + }, + { + name: 'Password', + description: '20 characters with symbols, ambiguous glyphs removed', + options: { + prefix: '', + separator: '', + length: 20, + alphabet: 'password', + excludeAmbiguous: true, + count: 5, + }, + }, + { + name: 'Readable code', + description: 'base58 with ambiguous glyphs removed, for codes people retype', + options: { prefix: '', separator: '', length: 10, alphabet: 'base58', excludeAmbiguous: true }, + }, +] diff --git a/src/tools/list/core/pipeline.ts b/src/tools/list/core/pipeline.ts index 750d2d4..76ca538 100644 --- a/src/tools/list/core/pipeline.ts +++ b/src/tools/list/core/pipeline.ts @@ -1,7 +1,7 @@ import { applyCase } from './casing' import { comparatorFor } from './compare' import { isBlank, parseLines, withLines } from './lines' -import { cryptoRandomSource, mulberry32, shuffleInPlace } from './shuffle' +import { cryptoRandomSource, mulberry32, shuffleInPlace } from '@/lib/random' import type { BlankPolicy, LineDoc, LineTest, Op, StepStat } from './types' function partitionBlanks(lines: string[], policy: BlankPolicy) { diff --git a/vitest.config.ts b/vitest.config.ts index 4a46fb8..00ee0a7 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ test: { // core/ is pure: no DOM needed, and node is much faster to boot. environment: 'node', - include: ['src/**/*.test.ts'], + include: ['src/**/*.test.ts', 'scripts/**/*.test.ts'], // Transforms dominate the run time otherwise. fsModuleCache: true, },