ci(release): ship the registration dialect with the version bump

The stable release asks the issuer for the registration dialect of the
version it cuts and commits it in the bump commit. Fetching and checking run
in a job of their own that holds no write token and keeps no git
credentials: scripts/ci/check-registration-dialect.mjs refuses a file that
imports or names anything beyond hashing, then runs it on its self-check
inputs. Only a file that passes reaches the bump as an artifact. When the
issuer is unreachable or its answer fails the check, the release goes ahead
with the dialect already on main and says so in the log and the summary.

Prereleases get no dialect of their own; dev takes the stable one through
the merge of main into dev after a release, and docker-dev.yml warns while
dev still speaks the original registration or a dialect older than 150 days.
This commit is contained in:
Maurice
2026-10-29 17:34:09 +01:00
parent 3ca73d432b
commit 6d245ab4da
4 changed files with 500 additions and 21 deletions
+25
View File
@@ -5,6 +5,10 @@ name: Build & Push Docker Image (Prerelease)
# :latest-pre, and tags the commit. Nothing moves :latest. Like the stable
# release it refuses a commit whose ci-ok is not green. docker.yml with
# bump=auto later finalizes the line once main carries it.
#
# A prerelease gets no registration dialect of its own; it carries the one of
# the last stable release, which dev gets by merging main into dev after that
# release. The build warns when that dialect is the original one or old.
on:
workflow_dispatch:
@@ -89,6 +93,27 @@ jobs:
echo "VERSION=$NEW_VERSION" >> "$GITHUB_OUTPUT"
echo "$STABLE to $NEW_VERSION"
# A prerelease registers new installations in the dialect of the last
# stable release, which reaches dev when main is merged into dev after
# the release. The issuer takes a dialect for 180 days, so this warns
# (without stopping the build) while dev still speaks the original
# registration or a dialect older than 150 days.
- name: Check the registration dialect's age
run: |
set -eu
file=server/src/nest/service-identity/registration-dialect.ts
id="$(sed -n "s/^const DIALECT = '\(.*\)';$/\1/p" "$file")"
created="$(sed -n "s/^ createdAt: '\(.*\)',$/\1/p" "$file")"
if [ "$id" = 0 ]; then
message="This build registers in the original dialect, which the issuer accepts only for a transition period. Merge main into dev once a stable release carries a dialect of its own."
else
age=$(( ($(date -u +%s) - $(date -u -d "$created" +%s)) / 86400 ))
[ "$age" -gt 150 ] || { echo "This build registers in dialect $id from $created ($age days old)."; exit 0; }
message="This build registers in dialect $id from $created, $age days old; new installations of it stop registering on $(date -u -d "$created + 180 days" +%Y-%m-%d). Merge main into dev after the next stable release."
fi
echo "::warning::$message"
printf '### Registration dialect\n%s\n' "$message" >> "$GITHUB_STEP_SUMMARY"
build:
runs-on: ${{ matrix.runner }}
timeout-minutes: 120
+177 -21
View File
@@ -2,17 +2,27 @@ name: Build & Push Docker Image
# The stable release. Dispatched by hand on main, it:
# 1. refuses to run unless ci-ok passed on the main commit it releases,
# 2. works out the version from the tags and the bump input, commits the
# bump to package.json, the lockfile and Chart.yaml, tags it and pushes,
# 3. builds both platforms from exactly that bump commit (never from
# 2. works out the version from the tags and the bump input,
# 3. asks the TREK services issuer for the registration dialect of that
# version and checks it, in a job without write access (a release without
# one ships the dialect already on main, with a warning),
# 4. commits the bump to package.json, the lockfile, Chart.yaml and the
# dialect, tags it and pushes,
# 5. builds both platforms from exactly that bump commit (never from
# whatever main points to by then) and publishes :VERSION,
# :MAJOR.MINOR, :MAJOR and :latest,
# 4. publishes the Helm chart from the same commit.
# 6. publishes the Helm chart from the same commit.
#
# Big or risky changes go out as a prerelease first (docker-dev.yml, tagged
# :latest-pre and :<next>-pre.N); bump=auto here then finalizes that line.
# Without a prerelease in flight there is no default: the run stops until
# someone picks patch, minor or major.
#
# Each stable release registers new installations in a dialect of its own,
# which the issuer accepts for 180 days; renewing a key needs none. dev and
# the prereleases built from it carry the dialect of the last stable release,
# so after a release, merge main into dev. docker-dev.yml warns when dev's
# dialect is the original one or getting old.
on:
workflow_dispatch:
@@ -36,13 +46,16 @@ concurrency:
cancel-in-progress: false
jobs:
version-bump:
version:
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-24.04
timeout-minutes: 120
timeout-minutes: 30
permissions:
contents: read
checks: read
outputs:
version: ${{ steps.bump.outputs.VERSION }}
sha: ${{ steps.bump.outputs.SHA }}
base: ${{ steps.base.outputs.SHA }}
steps:
- name: Release from main only
if: github.ref != 'refs/heads/main'
@@ -50,17 +63,17 @@ jobs:
echo "::error::Dispatch this workflow on main, not on $GITHUB_REF."
exit 1
- uses: actions/create-github-app-token@v3
id: app-token
with:
app-id: ${{ secrets.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
- uses: actions/checkout@v6
with:
fetch-depth: 0
fetch-tags: true
token: ${{ steps.app-token.outputs.token }}
persist-credentials: false
# The bump job checks out exactly this commit, so what ci-ok was checked
# on is what gets released, however long the dialect takes.
- name: Note the commit being released
id: base
run: echo "SHA=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
# The dev push, the main push and any manual run each leave a ci-ok on
# this commit. scripts/ci/ci-ok-verdict.mjs counts the newest attempt of
@@ -77,14 +90,13 @@ jobs:
gh api "repos/$GITHUB_REPOSITORY/commits/$sha/check-runs?check_name=ci-ok&filter=all&per_page=100" > "$runs"
node scripts/ci/ci-ok-verdict.mjs "$runs" "$sha"
- name: Determine bump type and update version
- name: Determine bump type and version
id: bump
env:
BUMP_INPUT: ${{ inputs.bump || 'auto' }}
CONFIRM_MAJOR: ${{ inputs.confirm_major }}
run: |
set -eu
git fetch --tags
# Derive version from git tags, no package.json dependency
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1)
@@ -143,6 +155,149 @@ jobs:
echo "VERSION=$NEW_VERSION" >> "$GITHUB_OUTPUT"
echo "$STABLE to $NEW_VERSION ($BUMP)"
# Fetches the registration dialect of this version from the TREK services
# issuer and checks it (scripts/ci/check-registration-dialect.mjs). The
# issuer's answer runs here, in a job that holds no write token and keeps no
# git credentials; only the checked file goes on, as an artifact. The job
# never fails the release: without a dialect, the release ships the one on
# main, which the issuer accepts for 180 days after it was made.
dialect:
needs: version
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
steps:
# Every step either cannot fail or is allowed to, so this job always
# succeeds and never holds up the bump.
- uses: actions/checkout@v6
continue-on-error: true
with:
ref: ${{ needs.version.outputs.base }}
persist-credentials: false
- uses: actions/setup-node@v6
continue-on-error: true
with:
node-version-file: .nvmrc
# The only step that sees the token. The URL is fixed; the answer is
# bounded in time and size and goes to a file nothing runs yet.
- name: Fetch the dialect from the issuer
id: fetch
env:
TREK_DIALECT_TOKEN: ${{ secrets.TREK_DIALECT_TOKEN }}
VERSION: ${{ needs.version.outputs.version }}
run: |
set -uo pipefail
out="$RUNNER_TEMP/registration-dialect.ts"
if [ -z "${TREK_DIALECT_TOKEN:-}" ]; then
echo "::warning::TREK_DIALECT_TOKEN is not set; $VERSION ships the registration dialect already on main."
exit 0
fi
status=$(curl --silent --show-error --proto '=https' --tlsv1.2 --max-time 30 --retry 2 --retry-delay 5 \
--max-filesize 262144 -H "Authorization: Bearer $TREK_DIALECT_TOKEN" -H 'Content-Type: application/json' \
--data "{\"version\":\"$VERSION\"}" -o "$out" -w '%{http_code}' https://places.liketrek.com/v1/dialects) \
|| status=000
if [ "$status" != 200 ] && [ "$status" != 201 ]; then
echo "::warning::The issuer handed out no registration dialect for $VERSION (HTTP $status); it ships the one on main."
rm -f "$out"
exit 0
fi
if [ "$(wc -c < "$out")" -gt 262144 ]; then
echo "::warning::The issuer's registration dialect for $VERSION is larger than a dialect; it ships the one on main."
rm -f "$out"
exit 0
fi
echo "file=$out" >> "$GITHUB_OUTPUT"
- name: Check the dialect
id: check
if: steps.fetch.outputs.file != ''
env:
FILE: ${{ steps.fetch.outputs.file }}
VERSION: ${{ needs.version.outputs.version }}
run: |
set -uo pipefail
if ! node scripts/ci/check-registration-dialect.mjs "$FILE" "$VERSION"; then
echo "::warning::The issuer's registration dialect for $VERSION failed its check; $VERSION ships the one on main."
exit 0
fi
echo "What changes against the dialect on main:"
git diff --no-index -- server/src/nest/service-identity/registration-dialect.ts "$FILE" || true
echo "ok=true" >> "$GITHUB_OUTPUT"
- name: Hand the dialect to the bump
if: steps.check.outputs.ok == 'true'
uses: actions/upload-artifact@v6
continue-on-error: true
with:
name: registration-dialect
path: ${{ steps.fetch.outputs.file }}
if-no-files-found: error
retention-days: 1
version-bump:
needs: [version, dialect]
runs-on: ubuntu-24.04
timeout-minutes: 120
outputs:
sha: ${{ steps.commit.outputs.SHA }}
steps:
- uses: actions/create-github-app-token@v3
id: app-token
with:
app-id: ${{ secrets.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
- uses: actions/checkout@v6
with:
ref: ${{ needs.version.outputs.base }}
fetch-depth: 0
fetch-tags: true
token: ${{ steps.app-token.outputs.token }}
- name: Take over the checked dialect
id: take
uses: actions/download-artifact@v7
continue-on-error: true
with:
name: registration-dialect
path: ${{ runner.temp }}/dialect
- name: Place the dialect
env:
VERSION: ${{ needs.version.outputs.version }}
TAKEN: ${{ steps.take.outcome }}
run: |
set -eu
target=server/src/nest/service-identity/registration-dialect.ts
fetched="$RUNNER_TEMP/dialect/registration-dialect.ts"
if [ "$TAKEN" = success ] && [ -f "$fetched" ]; then
cp "$fetched" "$target"
message="$VERSION registers in dialect $(sed -n "s/^const DIALECT = '\(.*\)';$/\1/p" "$target"), written by the issuer for it."
echo "$message"
printf '### Registration dialect\n%s\n' "$message" >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
id="$(sed -n "s/^const DIALECT = '\(.*\)';$/\1/p" "$target")"
created="$(sed -n "s/^ createdAt: '\(.*\)',$/\1/p" "$target")"
if [ "$id" = 0 ]; then
until="the end of the transition period the issuer allows the original registration"
else
until="$(date -u -d "$created + 180 days" +%Y-%m-%d)"
fi
message="$VERSION ships without a dialect of its own and registers in dialect $id from $created, which the issuer accepts for new installations until $until. The issuer's operator can write the dialect by hand; it then ships with the next patch."
echo "::warning::$message"
printf '### Registration dialect\n%s\n' "$message" >> "$GITHUB_STEP_SUMMARY"
- name: Update the version, commit, tag and push
id: commit
env:
NEW_VERSION: ${{ needs.version.outputs.version }}
run: |
set -eu
# Update all workspace + root package.json files and the root lockfile in one shot
npm version "$NEW_VERSION" --workspaces --include-workspace-root --no-git-tag-version
sed -i "s/^version: .*/version: $NEW_VERSION/" charts/trek/Chart.yaml
@@ -153,7 +308,8 @@ jobs:
# landed on main in the meantime.
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add package.json package-lock.json server/package.json client/package.json shared/package.json charts/trek/Chart.yaml
git add package.json package-lock.json server/package.json client/package.json shared/package.json \
charts/trek/Chart.yaml server/src/nest/service-identity/registration-dialect.ts
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
git tag -a "v$NEW_VERSION" -m "v$NEW_VERSION"
git push origin HEAD:main --follow-tags
@@ -164,7 +320,7 @@ jobs:
build:
runs-on: ${{ matrix.runner }}
timeout-minutes: 120
needs: version-bump
needs: [version, version-bump]
strategy:
fail-fast: false
matrix:
@@ -205,7 +361,7 @@ jobs:
outputs: type=image,name=mauriceboe/trek,push-by-digest=true,name-canonical=true,push=true
no-cache: true
build-args: |
APP_VERSION=${{ needs.version-bump.outputs.version }}
APP_VERSION=${{ needs.version.outputs.version }}
labels: |
org.opencontainers.image.revision=${{ steps.meta.outputs.revision }}
org.opencontainers.image.created=${{ steps.meta.outputs.created }}
@@ -227,7 +383,7 @@ jobs:
merge:
runs-on: ubuntu-24.04
timeout-minutes: 120
needs: [version-bump, build]
needs: [version, version-bump, build]
steps:
- uses: actions/checkout@v6
with:
@@ -252,7 +408,7 @@ jobs:
working-directory: /tmp/digests
run: |
set -euo pipefail
VERSION="${{ needs.version-bump.outputs.version }}"
VERSION="${{ needs.version.outputs.version }}"
MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)"
# :MAJOR.MINOR takes the patches of one minor line and nothing else,
# the conservative pin for an install that wants to wait out a
+211
View File
@@ -0,0 +1,211 @@
#!/usr/bin/env node
// Checks a registration dialect the issuer handed out, before a release ships it.
//
// docker.yml asks the TREK services issuer for the dialect of the version it
// releases and commits the answer as
// server/src/nest/service-identity/registration-dialect.ts. That file then
// runs inside every TREK server, so it is held to what a dialect is: a few
// hashes over the inputs it is given, nothing else.
//
// 1. Statically, before anything of it runs: the size, the header comment,
// imports from node:crypto, node:zlib and the types module only, none of
// the names that reach the process, the network, the file system, timers
// or code from strings, no computed property access and no backslash (so
// no escape can spell a forbidden name), and the version and id it must
// carry. The generated code needs none of these.
// 2. Then it is imported (Node strips the types) and run on the fixed inputs
// in its SELF_CHECK: the password, the salt, scrypt over them and the
// request must be what the issuer computed, and the request must pass
// the rules the issuer client applies (issuer.client.ts holds the same).
//
// Usage:
// node scripts/ci/check-registration-dialect.mjs <file.ts> <version>
//
// Prints what it found and exits 0 only when the dialect may ship. The release
// job runs it without any secret in its environment.
import { createHash, scryptSync } from 'node:crypto';
import { copyFileSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const MAX_BYTES = 64 * 1024;
const MAX_LINES = 1000;
const TYPES_MODULE = './registration-dialect.types';
const RUNTIME_MODULES = new Set(['node:crypto', 'node:zlib']);
const IMPORT_LINE = /^import (type )?\{[\w\s,]+\} from '([^']+)';$/;
// Each of these reaches past the hashes: the process and its environment,
// modules, the network, timers, or a way to run a string as code.
const FORBIDDEN = [
/\bprocess\b/,
/\brequire\b/,
/\bimport\s*\(/,
/\bimport\s*\.\s*meta\b/,
/\beval\b/,
/\bFunction\b/,
/\bglobalThis\b/,
/\bglobal\b/,
/\bmodule\s*\./,
/\bexports\s*\./,
/\bfetch\b/,
/\bXMLHttpRequest\b/,
/\bWebSocket\b/,
/\bEventSource\b/,
/\bWorker\b/,
/\bWebAssembly\b/,
/\bchild_process\b/,
/node:(?!crypto'|zlib')/,
/\bset(Timeout|Interval|Immediate)\b/,
/\bqueueMicrotask\b/,
/\bconstructor\b/,
/\bprototype\b/,
/\b__proto__\b/,
/\bReflect\b/,
/\bProxy\b/,
/\bexport\s+default\b/,
];
// x[...] and x?.[...]; a type annotation's T[] is all the generated code holds.
const COMPUTED_ACCESS = /([\w$)\]]\s*|\?\.\s*)\[(?!\])/;
const CHALLENGE_PATH = /^\/v1\/register(\/[A-Za-z0-9_-]{12})?\/challenge$/;
const REGISTER_PATH = /^\/v1\/register(\/[A-Za-z0-9_-]{12})?$/;
const DIALECT_HEADER = /^X-TREK-[A-Za-z0-9-]{1,40}$/;
const RESERVED_HEADER = /^X-TREK-(Install|Tier|Legacy|Quota|Instance|Notice)(-|$)/i;
const HEADER_VALUE = /^[\x20-\x7e]{1,256}$/;
const MAX_REGISTRATION_BYTES = 2048;
const LARGEST_NONCE = 2 ** 40;
const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// What is wrong with the source before it runs; empty when nothing is.
export function staticProblems(source, version) {
const problems = [];
if (Buffer.byteLength(source, 'utf8') > MAX_BYTES) problems.push(`it is larger than ${MAX_BYTES} bytes`);
const lines = source.split('\n');
if (lines.length > MAX_LINES) problems.push(`it is longer than ${MAX_LINES} lines`);
if (!source.startsWith('/**\n')) problems.push('it does not start with its header comment');
if (source.includes('\\')) problems.push('it holds a backslash');
if (source.includes('\r')) problems.push('it holds a carriage return');
for (const [index, line] of lines.entries()) {
if (!/^\s*(import|export\s+\*|export\s*\{)/.test(line) && !/\bfrom\s+'/.test(line)) continue;
const match = IMPORT_LINE.exec(line);
const allowed = match && (match[1] ? match[2] === TYPES_MODULE : RUNTIME_MODULES.has(match[2]));
if (!allowed) problems.push(`line ${index + 1} imports or re-exports what a dialect does not: ${line.trim()}`);
}
for (const pattern of FORBIDDEN) {
const line = lines.findIndex((text) => pattern.test(text));
if (line >= 0) problems.push(`line ${line + 1} names what a dialect does not need (${pattern.source})`);
}
const computed = lines.findIndex((text) => COMPUTED_ACCESS.test(text));
if (computed >= 0) problems.push(`line ${computed + 1} reads a property by a computed name`);
if (!new RegExp(`^ releasedFor: '${escapeRegExp(version)}',$`, 'm').test(source)) {
problems.push(`it is not written for ${version}`);
}
if (!/^const DIALECT = '[A-Za-z0-9_-]{12}';$/m.test(source)) problems.push('it does not name a dialect id');
if (!/^export const registrationDialect: RegistrationDialect = \{$/m.test(source)) {
problems.push('it does not export registrationDialect');
}
if (!/^export const SELF_CHECK: DialectSelfCheck = \{$/m.test(source)) problems.push('it does not export SELF_CHECK');
return problems;
}
const sha256 = (data) => createHash('sha256').update(data).digest('hex');
function requestProblems(path, request) {
const problems = [];
if (!REGISTER_PATH.test(path)) problems.push(`it registers at ${path}`);
for (const [name, value] of Object.entries(request.headers ?? {})) {
if (
!DIALECT_HEADER.test(name) ||
RESERVED_HEADER.test(name) ||
typeof value !== 'string' ||
!HEADER_VALUE.test(value)
) {
problems.push(`it sends the header ${name}, which TREK does not send`);
}
}
const body = JSON.stringify(request.body);
if (typeof body !== 'string' || Buffer.byteLength(body, 'utf8') > MAX_REGISTRATION_BYTES) {
problems.push('its body is no registration');
}
return problems;
}
// What is wrong with the imported module; empty when nothing is.
export function runtimeProblems(mod, version) {
const dialect = mod.registrationDialect;
const check = mod.SELF_CHECK;
if (!dialect || !check) return ['it exports no registrationDialect or no SELF_CHECK'];
const problems = [];
if (dialect.releasedFor !== version) problems.push(`it is released for ${dialect.releasedFor}, not ${version}`);
if (!/^[A-Za-z0-9_-]{12}$/.test(dialect.id)) problems.push(`its id ${dialect.id} is not an issuer's`);
if (!/^\d{4}-\d{2}-\d{2}$/.test(dialect.createdAt) || Number.isNaN(Date.parse(dialect.createdAt))) {
problems.push(`its date ${dialect.createdAt} is no date`);
}
if (!CHALLENGE_PATH.test(dialect.challengePath) || dialect.challengePath !== `/v1/register/${dialect.id}/challenge`) {
problems.push(`it asks for a challenge at ${dialect.challengePath}`);
}
if (dialect.registerPath !== `/v1/register/${dialect.id}`) problems.push(`it registers at ${dialect.registerPath}`);
const inputs = {
challenge: check.challenge,
version: check.version,
random: Buffer.from(check.random, 'hex'),
nowSeconds: check.nowSeconds,
};
const attempt = dialect.begin(inputs);
const { password, salt } = attempt.work(check.nonce);
if (sha256(password) !== check.passwordSha256) problems.push('its password is not the one the issuer computed');
if (sha256(salt) !== check.saltSha256) problems.push('its salt is not the one the issuer computed');
const { N, r, p } = check.challenge;
if (scryptSync(password, salt, 32, { N, r, p }).toString('hex') !== check.scrypt) {
problems.push('its proof of work is not the one the issuer computed');
}
const request = attempt.request(check.nonce);
if (sha256(JSON.stringify({ body: request.body, headers: request.headers })) !== check.requestSha256) {
problems.push('its request is not the one the issuer computed');
}
problems.push(...requestProblems(dialect.registerPath, request));
problems.push(...requestProblems(dialect.registerPath, attempt.request(LARGEST_NONCE)));
return [...new Set(problems)];
}
// Runs a checked source: Node strips the types of an .mts file and imports it.
async function importDialect(file) {
const dir = mkdtempSync(join(tmpdir(), 'registration-dialect-'));
try {
const copy = join(dir, 'registration-dialect.mts');
copyFileSync(file, copy);
return await import(pathToFileURL(copy).href);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
export async function checkDialect(file, version) {
const source = readFileSync(file, 'utf8');
const found = staticProblems(source, version);
if (found.length > 0) return found;
try {
return runtimeProblems(await importDialect(file), version);
} catch (err) {
return [`it fails when run: ${err instanceof Error ? err.message : err}`];
}
}
async function main() {
const [file, version] = process.argv.slice(2);
if (!file || !version) throw new Error('usage: check-registration-dialect.mjs <file.ts> <version>');
const problems = await checkDialect(file, version);
for (const problem of problems) console.log(`The dialect for ${version} is refused: ${problem}.`);
if (problems.length > 0) process.exit(1);
console.log(`The dialect for ${version} computes what the issuer computed.`);
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
main().catch((err) => {
console.error(`::error::${err instanceof Error ? err.message : err}`);
process.exit(1);
});
}
@@ -0,0 +1,87 @@
import { checkDialect, staticProblems } from './check-registration-dialect.mjs';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { after, describe, it } from 'node:test';
import { fileURLToPath } from 'node:url';
// The issuer's sample: every building block a generated dialect can use, for version 9.9.9.
const SAMPLE = fileURLToPath(
new URL('../../server/tests/fixtures/service-identity/registration-dialect.sample.ts', import.meta.url),
);
const source = readFileSync(SAMPLE, 'utf8');
const scratch = mkdtempSync(join(tmpdir(), 'registration-dialect-check-'));
after(() => rmSync(scratch, { recursive: true, force: true }));
let written = 0;
function variant(text) {
written += 1;
const file = join(scratch, `variant-${written}.ts`);
writeFileSync(file, text);
return file;
}
function replaced(from, to) {
assert.ok(source.includes(from), `the sample no longer holds ${from}`);
return source.replace(from, to);
}
describe('check-registration-dialect', () => {
it('passes the issuer sample for its own version', async () => {
assert.deepEqual(await checkDialect(SAMPLE, '9.9.9'), []);
});
it('refuses a dialect written for another version', async () => {
const problems = await checkDialect(SAMPLE, '9.9.10');
assert.ok(problems.some((p) => p.includes('not written for 9.9.10')));
});
it('refuses an import from anywhere but node:crypto, node:zlib and the types', () => {
const text = replaced(
"import { crc32 } from 'node:zlib';",
"import { crc32 } from 'node:zlib';\nimport { readFileSync } from 'node:fs';",
);
assert.ok(staticProblems(text, '9.9.9').some((p) => p.includes("from 'node:fs'")));
const runtimeTypes = replaced('import type {', 'import {');
assert.ok(staticProblems(runtimeTypes, '9.9.9').some((p) => p.includes('registration-dialect.types')));
});
it('refuses what reaches the process, the network or code from strings, before running any of it', async () => {
for (const line of ['const env = process.env;', 'void fetch("https://example.org");', 'const run = eval;']) {
const file = variant(replaced('const DIALECT =', `${line}\nconst DIALECT =`));
const problems = await checkDialect(file, '9.9.9');
assert.ok(
problems.some((p) => p.includes('names what a dialect does not need')),
line,
);
}
});
it('refuses a computed property name and any escape that could spell one', () => {
const computed = replaced('const DIALECT =', "const name = BASE32['length'];\nconst DIALECT =");
assert.ok(staticProblems(computed, '9.9.9').some((p) => p.includes('computed name')));
const escaped = replaced('const DIALECT =', "const name = 'pro\\u0063ess';\nconst DIALECT =");
assert.ok(staticProblems(escaped, '9.9.9').some((p) => p.includes('backslash')));
});
it('refuses a file without its header or without a dialect id', () => {
assert.ok(staticProblems(source.slice(source.indexOf('import')), '9.9.9').some((p) => p.includes('header')));
const noId = replaced("const DIALECT = 'KitchenSink0';", "const DIALECT = 'short';");
assert.ok(staticProblems(noId, '9.9.9').some((p) => p.includes('dialect id')));
});
it('refuses a dialect that does not compute what its SELF_CHECK says', async () => {
const file = variant(replaced("seal11.toString('base64url')", "seal11.toString('hex')"));
const problems = await checkDialect(file, '9.9.9');
assert.deepEqual(problems, ['its request is not the one the issuer computed']);
});
it('refuses a dialect whose request TREK would not send', async () => {
const file = variant(replaced("'X-TREK-Ferry-Seal'", "'X-TREK-Install'"));
const problems = await checkDialect(file, '9.9.9');
assert.ok(problems.some((p) => p.includes('X-TREK-Install')));
});
});