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
121 changes: 121 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# TGWAB CI, library and extension shape—DEV-STANDARDS §15.
#
# Place at: .github/workflows/ci.yml
#
# WHEN TO USE THIS INSTEAD OF templates/ci.yml. The site template gates a BUILD:
# it runs `npm run build`, scans `dist/` for eval and for mailto:, and drives a
# Playwright harness under the real _headers CSP. A package that ships its source
# has no dist/, no CSP and no pages, so every one of those steps either fails or
# passes vacuously. This template is the same contract with the build-shaped
# gates removed—not a weaker one.
#
# Use it for: tool/feature packages consumed by a site (textwizard-tools,
# punctuationwizard-tools, markdownwizard-tools) and browser extensions whose
# "build" is a store zip rather than a site.
# Use templates/ci.yml for: anything that produces a deployable dist/.
#
# The job is named `ci` because templates/ruleset-main.json requires the
# status-check context "ci" (§4). Rename one and you MUST rename both, or merges
# block forever. A repo whose existing job is named something else must change
# the ruleset in the SAME change, or the required check never reports.
#
# Action SHAs are pinned per §15—a tag is mutable, a SHA is not. Re-resolve
# before first use:
# gh api repos/actions/checkout/git/refs/tags/v7.0.1 --jq .object.sha

name: ci

on:
pull_request:
push:
branches: [main]

permissions: {}

concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
ci: # job name == the required status-check context
runs-on: ubuntu-latest
# NOT 15. templates/ci.yml carried `timeout-minutes: 15` for months after
# devops fixed that exact default downstream in capturewizard-api #24 and
# bookmarkwizard-api #8—the repos got fixed and the template that produced
# them did not. Install plus lint plus a unit suite on a source-only package
# is well under a minute; 10 is roughly ten times headroom, and a run that
# exceeds it is wedged rather than slow.
timeout-minutes: 10
permissions:
contents: read
# A repo that genuinely cannot run one of the gates below uncomments the
# matching line and names the REASON—the MUSTER_OPT_OUT=1 pattern. The step
# prints it as a run annotation, so the reason travels with every run instead
# of living in someone's memory. "Not adopted yet" is not a reason.
# env:
# LINT_OPT_OUT: "…"
# TEST_OPT_OUT: "…"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc # never a hardcoded node-version: (§15)
cache: npm

# READ THIS BEFORE ADOPTING: `npm ci` REQUIRES package-lock.json and fails
# outright without one. It is not the safe boilerplate it looks like, and
# it is the first step that will break.
#
# Several packages in the estate have a package.json and NO lockfile—they
# had no dependencies, so nothing ever generated one. Adding `eslint` as
# the first devDependency generates it, which means the adopting PR CREATES
# the lockfile as part of the change and must commit it. A repo that adds
# this workflow without committing a lockfile fails here on its first run.
#
# Exits non-zero if package-lock.json has drifted from package.json.
- run: npm ci

# §15 gate: lint MUST run, and MUST fail when there is no lint to run.
# Deliberately NOT `npm run lint --if-present`: npm exits 0 when the script
# is absent, so that form reports a passing lint which never executed. A
# control that passes by not running is the defect class lint exists to
# catch, and a library template shipping it would recreate the gap #105
# closed.
- name: lint
run: |
set -euo pipefail
if [ -n "${LINT_OPT_OUT:-}" ]; then
echo "::notice title=lint opted out (§15)::${LINT_OPT_OUT}"
exit 0
fi
if ! node -e "process.exit((require('./package.json').scripts||{}).lint?0:1)"; then
echo "::error title=no lint script (§15)::CI MUST run lint. Add a \"lint\" script (eslint .) plus eslint.config.js from templates/, or declare LINT_OPT_OUT with a reason on the ci job."
exit 1
fi
npm run lint

# §15 gate: the test suite, on the same terms and for the same reason.
#
# This is not boilerplate symmetry. A repo whose source renders HTML MUST
# ship the interpolated-JSON fixture (§15), and a fixture that no CI run
# executes is decoration—the precise failure the fixture itself exists to
# prevent, one level up. So a repo carrying the fixture and no test script
# must fail here rather than quietly skip.
- name: test
run: |
set -euo pipefail
if [ -n "${TEST_OPT_OUT:-}" ]; then
echo "::notice title=tests opted out (§15)::${TEST_OPT_OUT}"
exit 0
fi
if ! node -e "process.exit((require('./package.json').scripts||{}).test?0:1)"; then
echo "::error title=no test script (§15)::A repo shipping the §15 fixture MUST run it. Add a \"test\" script, or declare TEST_OPT_OUT with a reason on the ci job."
exit 1
fi
npm test

# Production tree only—a dev-tree advisory never reaches a user (§15).
- run: npm audit --omit=dev --audit-level=high
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
node_modules/
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
24
213 changes: 213 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
// TGWAB — the interpolated-JSON XSS rule. DEV-STANDARDS §15.
//
// Place at: eslint.config.js (repo root; merge into an existing flat config)
// Fixture: templates/xss-lint-fixture.js → test/xss-lint-fixture.test.js
//
// WHAT IT CATCHES. `JSON.stringify` interpolated into a template literal that
// becomes HTML. A string ending `</script>` closes the tag early and everything
// after it is markup, so a value the user controls becomes script. The remedy is
// never a different escaper — it is to stop putting data in a script body:
//
// BAD `<script>window.__D__ = ${JSON.stringify(d)};</script>`
// GOOD `<div id="d" data-payload="${escapeAttr(JSON.stringify(d))}"></div>`
// …then JSON.parse(el.dataset.payload) on the client.
//
// WHY THE SELECTOR IS A DESCENDANT (` `) AND NOT A DIRECT CHILD (` > `).
// This is measured, not argued. Both forms were run against the seven-case
// fixture that ships beside this file:
//
// TemplateLiteral > CallExpression[…] catches 2 of 5 hazards
// TemplateLiteral CallExpression[…] catches 5 of 5
//
// The three the direct-child form misses are ordinary code, not exotic:
//
// ${n ? JSON.stringify(n) : 'null'} the call is under a Conditional
// ${escapeHtml(JSON.stringify(n))} the call is an argument
// ${xs.map((x) => JSON.stringify(x))} the call is inside an arrow body
//
// In each the CallExpression is a GRANDCHILD of the TemplateLiteral, so `>` does
// not match. Both forms leave both negative controls alone, so the descendant
// form is strictly better rather than merely louder. Re-run the fixture before
// believing this comment.
//
// ON `${escapeHtml(JSON.stringify(x))}` — READ THIS BEFORE DECIDING THE RULE IS
// WRONG. The rule flags it, and it is NOT exploitable: HTML entities are not
// decoded inside a `<script>` element, so `&lt;/script&gt;` cannot close the tag.
// What you get is broken JavaScript, not XSS. It is still the wrong pattern, the
// remedy is the same data attribute, and the flag is correct — but the finding is
// "this does not work", not "this is a hole". Say that in review, or someone will
// conclude the rule is wrong rather than that their code is.
//
// The rule is deliberately blunt: it does not test whether the literal contains
// `<script`. A rule gated on that misses every case where the tags come from a
// wrapper — which is the bug resizewizard-api's rule was written for and does not
// catch. Blunt plus a declared opt-out beats clever and silent.

import js from '@eslint/js';
import globals from 'globals';

export default [
// ---------------------------------------------------------------------------
// WHAT IS NOT LINTED: generated output and vendored code — files nobody here
// wrote. Global ignores, so they apply to every block below.
//
// THE `**/` PREFIXES ARE LOAD-BEARING, and this is measured. A bare `dist/`
// matches the REPO-ROOT dist only. The estate keeps 24 `dist/`, 29 `.astro/`,
// and 33 `.wrangler/` directories and they are routinely nested —
// `herald/ui/dist`, `muster/worker/.wrangler`, `tomatick/site/.astro`. The
// shipped `dist/` caught the root one and missed every nested one.
//
// `vendor` is the one that actually bites in CI, and NOT `dist`: lint runs
// before build, so `dist/` does not exist yet on a CI run. `ipcow.com` alone
// reports 148 errors, every one of them inside one committed
// `public/vendor/leaflet/leaflet.js`. Vendored code sits at six different
// depths across the estate and one of them (`mykk.us-extension/worker/src/
// vendor`) is UNDER `src/`, so it defeated the old scope too.
// ---------------------------------------------------------------------------
{
ignores: [
'**/node_modules/',
'**/dist/',
'**/build/',
'**/.astro/',
'**/.wrangler/',
'**/vendor/**',
'**/*.min.js',

// The §15 fixture is a SPECIMEN, and this line is what keeps `npm run
// lint` green in a repo that complies with §15 by shipping it. Measured:
// without this, the fixture's five deliberate hazards become five errors
// in every adopting repo, and the obvious "fix" is to delete the control.
//
// DO NOT rename the test's synthetic lint path to match this pattern. The
// test lints the fixture's TEXT under `src/__xss-lint-fixture__.js`; the
// leading and trailing underscores are why this ignore does not swallow
// it. Rename it to `src/xss-lint-fixture.js` and the test goes green
// reporting zero findings — a control that passes by not running, which
// is the exact failure this fixture exists to prevent.
'**/xss-lint-fixture.js',
],
},

js.configs.recommended,
{
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
// `webextensions` supplies `chrome` and `browser`. Without it every
// `chrome.*` call in an extension repo is a `no-undef` error, which is a
// large share of the noise in exactly the repos §15 most needs to reach.
globals: { ...globals.browser, ...globals.node, ...globals.webextensions },
},
},
{
rules: {
// ESLint 9 changed `caughtErrors` from 'none' to 'all', so every
// `catch (e) {}` that ignores its binding became an error on upgrade.
// Measured: 57 of 80 unused-variable findings estate-wide are caught
// errors, 33 of them the same copied `test/harness.mjs` across the viewer
// family. This restores the pre-9 default rather than editing 33 copies
// of a harness to say `catch {}`.
'no-unused-vars': ['error', { caughtErrors: 'none' }],
},
},

// ---------------------------------------------------------------------------
// §15: required in any repo whose source renders HTML.
//
// WHY THE SCOPE IS `**/*.js` AND NOT A LIST OF DIRECTORIES. Read this before
// narrowing it, because narrowing it is how this rule was inert on arrival.
//
// The shipped scope was `src/**/*.js`. Five repos have ZERO files under
// `src/` — their JavaScript sits at the repo root — so the rule matched
// nothing at all in them:
//
// copywizard-extension 0 under src/, 5 .js at root
// capturewizard-extension 0 under src/, 10 .js at root
// bookmarkwizard-extension 0 under src/, 6 .js at root
// mykk.us-extension 0 under src/, 2 .js at root
// textwizard-tools 0 under src/, 1 .js at root
//
// Those are the browser extensions — the repos that render untrusted content
// into a DOM, and so the ones this rule exists for. A security control that
// covers everything except its highest-risk consumers is not a partial
// control; it is a control that passes by not running.
//
// `*.js` + `src/**/*.js` would have fixed those five and NOTHING ELSE. The
// estate already has a third layout and a fourth — `apps/<app>/src/`,
// `worker/src/`, `js/`, `functions/` — and an enumerated list meets each new
// one by silently matching zero files in it. That is the same defect again,
// rediscovered later by whoever is unlucky.
//
// `**/*.js` cannot fail that way. Its failure mode is the opposite and the
// safe one: a layout nobody anticipated gets linted, and if that surfaces
// vendored or generated code the answer is a named entry in the ignores
// above, which is reviewable. Too many findings is a conversation. Zero
// findings looks exactly like success.
// ---------------------------------------------------------------------------
{
files: ['**/*.js'],
rules: {
'no-restricted-syntax': [
'error',
{
selector:
'TemplateLiteral CallExpression[callee.object.name="JSON"][callee.property.name="stringify"]',
message:
'Do not interpolate JSON.stringify into a template literal — a value containing "</script>" closes the tag and becomes markup. Pass it through a data attribute and JSON.parse it on the client (DS §15).',
},
],
},
},

// ---------------------------------------------------------------------------
// THE OPT-OUT IS A DECLARATION, NOT AN ABSENCE.
//
// A repo that legitimately interpolates JSON into something that is not HTML
// uncomments this block and names EVERY path with its reason. The point is the
// same as MUSTER_OPT_OUT=1: a silence cannot be reviewed, and a repo that
// simply never adopted the rule is indistinguishable from one that considered
// it. A named exemption is a claim someone can check.
//
// Do NOT widen this to a whole directory to make a run green. One line per
// reason, and if you cannot write the reason, the exemption is wrong.
//
// Known exemptions from the sweep of 75 clones, as worked examples:
//
// proton-mail-bridge-client — 12 sites under src/: CLI stdout in --json
// mode, a JSON config write, an NDJSON audit append, an AppleScript
// string. None reaches a browser.
// tgwab-packages/packages/auth/src/licenseStore.js — storage payload.
// ipcow.com/apps/checkip/src/index.ts — a JSON API response.
//
// That last one is why "it returns a Response" cannot be the discriminator: a
// Response is exactly what an HTML page is returned as too. The discriminator
// is whether the string is ever parsed as HTML, and only a human knows that.
// ---------------------------------------------------------------------------
// {
// files: [
// 'src/cli/output.js', // --json mode writes to stdout, never a page
// 'src/config/write.js', // JSON config file on disk
// ],
// rules: { 'no-restricted-syntax': 'off' },
// },

// ---------------------------------------------------------------------------
// DECLARED EXEMPTION — §15's opt-out, with its reason.
//
// features/code-data.js builds a regex-match REPORT, not markup. Its two
// flagged lines push `@12 "matched"` strings into an array that ends in
// `lines.join('\n')` and is rendered as plain text. Nothing here is parsed as
// HTML, so the `</script>` escape the rule exists to stop cannot happen.
//
// The exemption is a file, not the rule, and it names the reason -- a repo
// that simply never adopted the rule is indistinguishable from one that
// considered it, and only the second can be reviewed. "It returns a string"
// would NOT be a valid reason; "it is joined with newlines and written to a
// <pre> as text" is, and that is what was checked.
// ---------------------------------------------------------------------------
{
files: ['features/code-data.js'],
rules: { 'no-restricted-syntax': 'off' },
},
];
5 changes: 2 additions & 3 deletions features/_editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ export function createEditor({
placeholderText,
toolbarLeft = [],
toolbarRight = [],
buttons = [],
options = [],
optionDefaults = {},
}) {
Expand Down Expand Up @@ -41,7 +40,7 @@ export function createEditor({
try {
const saved = JSON.parse(localStorage.getItem(optStorageKey) || 'null');
if (saved && typeof saved === 'object') Object.assign(optionState, saved);
} catch {}
} catch { /* unreadable or private-mode storage: fall back to defaults */ }

// Render options
if (options.length) {
Expand Down Expand Up @@ -162,7 +161,7 @@ export function createEditor({
try {
const parsed = JSON.parse(localStorage.getItem(saveKey) || 'null');
if (parsed && parsed.name && parsed.ext) last = parsed;
} catch {}
} catch { /* unreadable or private-mode storage: `last` stays null */ }

const backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop';
Expand Down
1 change: 0 additions & 1 deletion features/analyzers.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,6 @@ export const analyzers = {
container,
storageKey: 'tw::analyzers',
placeholderText: PLACEHOLDER,
buttons: ACTIONS,
toolbarLeft: [
{
label: 'Copy',
Expand Down
1 change: 0 additions & 1 deletion features/case-converter.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,6 @@ export const caseConverter = {
container,
storageKey: 'tw::case-converter',
placeholderText: PLACEHOLDER,
buttons: ACTIONS,
toolbarLeft: [
{
label: 'Copy',
Expand Down
1 change: 0 additions & 1 deletion features/code-data.js
Original file line number Diff line number Diff line change
Expand Up @@ -450,7 +450,6 @@ export const codeData = {
container,
storageKey: 'tw::code-data',
placeholderText: PLACEHOLDER,
buttons: ACTIONS,
toolbarLeft: [
{ label: 'Copy', className: 'tw-btn tw-btn-primary', onClick: ({ getValue }) => copyOutput(getValue)() },
],
Expand Down
Loading