From 9aac90db599cdd4fb25fccbeec683dd66a2a1f63 Mon Sep 17 00:00:00 2001 From: Erhan Acet Date: Thu, 26 Feb 2026 14:18:25 +0300 Subject: [PATCH 1/2] feat(pq-jws/ts): phase 2 - implement compact jws core utilities (ENG-1641) --- packages/pq-jws/ts/src/base64url.ts | 114 +++++++- packages/pq-jws/ts/src/compact.ts | 294 ++++++++++++++++++++- packages/pq-jws/ts/src/jws.ts | 29 +- packages/pq-jws/ts/src/types.ts | 13 + packages/pq-jws/ts/tests/base64url.test.ts | 43 +++ packages/pq-jws/ts/tests/compact.test.ts | 163 ++++++++++++ 6 files changed, 644 insertions(+), 12 deletions(-) create mode 100644 packages/pq-jws/ts/tests/base64url.test.ts create mode 100644 packages/pq-jws/ts/tests/compact.test.ts diff --git a/packages/pq-jws/ts/src/base64url.ts b/packages/pq-jws/ts/src/base64url.ts index 4281ae5..a696307 100644 --- a/packages/pq-jws/ts/src/base64url.ts +++ b/packages/pq-jws/ts/src/base64url.ts @@ -1,9 +1,113 @@ -import { JwsError } from './errors'; +import { JwsFormatError } from './errors'; -export function encodeBase64Url(_value: Uint8Array): string { - throw new JwsError('encodeBase64Url is not implemented yet.'); +const BASE64URL_PATTERN = /^[A-Za-z0-9_-]*$/; +const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; +const BASE64_VALUES: Record = {}; + +for (let i = 0; i < BASE64_ALPHABET.length; i += 1) { + BASE64_VALUES[BASE64_ALPHABET[i]] = i; +} + +declare const Buffer: + | { + from(data: Uint8Array): { toString(encoding: 'base64'): string }; + from(data: string, encoding: 'base64'): Uint8Array; + } + | undefined; + +function bytesToBinaryString(bytes: Uint8Array): string { + let output = ''; + for (let i = 0; i < bytes.length; i += 1) { + output += String.fromCharCode(bytes[i]); + } + return output; +} + +function binaryStringToBytes(input: string): Uint8Array { + const bytes = new Uint8Array(input.length); + for (let i = 0; i < input.length; i += 1) { + bytes[i] = input.charCodeAt(i); + } + return bytes; +} + +function validateBase64UrlInput(value: string): void { + if (value.includes('=')) { + throw new JwsFormatError('Compact JWS segments must not use base64url padding.'); + } + + if (!BASE64URL_PATTERN.test(value)) { + throw new JwsFormatError('Compact JWS segments must be unpadded base64url.'); + } + + if (value.length % 4 === 1) { + throw new JwsFormatError('Invalid base64url length.'); + } +} + +function validateTrailingBits(base64: string): void { + const unpadded = base64.replace(/=+$/g, ''); + const remainder = unpadded.length % 4; + + if (remainder === 2) { + if ((BASE64_VALUES[unpadded[unpadded.length - 1]] & 0x0f) !== 0) { + throw new JwsFormatError('Invalid base64url trailing bits.'); + } + return; + } + + if (remainder === 3) { + if ((BASE64_VALUES[unpadded[unpadded.length - 1]] & 0x03) !== 0) { + throw new JwsFormatError('Invalid base64url trailing bits.'); + } + } +} + +function toBase64(value: string): string { + const normalized = value.replace(/-/g, '+').replace(/_/g, '/'); + const remainder = normalized.length % 4; + if (remainder === 0) { + return normalized; + } + return normalized + '='.repeat(4 - remainder); +} + +export function encodeBase64Url(value: Uint8Array): string { + if (typeof globalThis.btoa === 'function') { + return globalThis + .btoa(bytesToBinaryString(value)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); + } + + if (typeof Buffer !== 'undefined') { + return Buffer.from(value) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); + } + + throw new JwsFormatError('No base64 encoder available.'); } -export function decodeBase64Url(_value: string): Uint8Array { - throw new JwsError('decodeBase64Url is not implemented yet.'); +export function decodeBase64Url(value: string): Uint8Array { + validateBase64UrlInput(value); + if (value.length === 0) { + return new Uint8Array(); + } + + const base64 = toBase64(value); + validateTrailingBits(base64); + + if (typeof globalThis.atob === 'function') { + return binaryStringToBytes(globalThis.atob(base64)); + } + + if (typeof Buffer !== 'undefined') { + return Buffer.from(base64, 'base64'); + } + + throw new JwsFormatError('No base64 decoder available.'); } diff --git a/packages/pq-jws/ts/src/compact.ts b/packages/pq-jws/ts/src/compact.ts index 58a7729..9e34a42 100644 --- a/packages/pq-jws/ts/src/compact.ts +++ b/packages/pq-jws/ts/src/compact.ts @@ -1,6 +1,292 @@ -import { JwsError } from './errors'; -import type { ParsedCompactJws } from './types'; +import { decodeBase64Url, encodeBase64Url } from './base64url'; +import { JwsFormatError, JwsValidationError } from './errors'; +import type { + CompactJwsSegments, + JwsCompactParseOptions, + JwsCompactParseOptionsInput, + JwsProtectedHeader, + ParsedCompactJws, +} from './types'; -export function parseJwsCompact(_compact: string): ParsedCompactJws { - throw new JwsError('parseJwsCompact is not implemented yet.'); +const textEncoder = new TextEncoder(); +const utf8Decoder = new TextDecoder('utf-8', { fatal: true }); + +export const DEFAULT_JWS_COMPACT_PARSE_OPTIONS: JwsCompactParseOptions = { + maxCompactLength: 262_144, + maxHeaderLength: 16_384, + maxPayloadLength: 112_640, + maxSignatureLength: 65_536, +}; + +function maxEncodedLength(decodedLength: number): number { + return Math.ceil(decodedLength / 3) * 4; +} + +function validateParseOptionRange(name: keyof JwsCompactParseOptions, value: number): void { + if (!Number.isFinite(value) || value < 1 || !Number.isInteger(value)) { + throw new JwsValidationError(`${name} must be a positive integer.`); + } +} + +function assertDefaultBoundsConsistency(options: JwsCompactParseOptions): void { + const maximumPossibleLength = + maxEncodedLength(options.maxHeaderLength) + + maxEncodedLength(options.maxPayloadLength) + + maxEncodedLength(options.maxSignatureLength) + + 2; + + if (maximumPossibleLength > options.maxCompactLength) { + throw new JwsValidationError( + 'Compact JWS parse bounds are internally inconsistent for base64url segment expansion.', + ); + } +} + +export function resolveJwsCompactParseOptions( + overrides: JwsCompactParseOptionsInput = {}, +): JwsCompactParseOptions { + const options: JwsCompactParseOptions = { + ...DEFAULT_JWS_COMPACT_PARSE_OPTIONS, + ...overrides, + }; + + validateParseOptionRange('maxCompactLength', options.maxCompactLength); + validateParseOptionRange('maxHeaderLength', options.maxHeaderLength); + validateParseOptionRange('maxPayloadLength', options.maxPayloadLength); + validateParseOptionRange('maxSignatureLength', options.maxSignatureLength); + assertDefaultBoundsConsistency(options); + + return options; +} + +function extractTopLevelKeys(json: string): string[] { + const keys: string[] = []; + let i = 0; + + while (i < json.length && json[i] !== '{') { + i += 1; + } + + if (i >= json.length) { + return keys; + } + + i += 1; + let depth = 0; + + while (i < json.length) { + const ch = json[i]; + + if (ch === '"') { + const start = i + 1; + i += 1; + while (i < json.length && json[i] !== '"') { + if (json[i] === '\\') { + i += 1; + } + i += 1; + } + + const end = i; + i += 1; + + if (depth === 0) { + let j = i; + while (j < json.length && [' ', '\t', '\n', '\r'].includes(json[j])) { + j += 1; + } + + if (json[j] === ':') { + const raw = json.slice(start, end); + try { + keys.push(JSON.parse(`"${raw}"`)); + } catch { + keys.push(raw); + } + } + } + } else if (ch === '{' || ch === '[') { + depth += 1; + i += 1; + } else if (ch === '}' || ch === ']') { + if (depth === 0) { + break; + } + depth -= 1; + i += 1; + } else { + i += 1; + } + } + + return keys; +} + +function decodeProtectedHeader( + encodedProtectedHeader: string, + maxHeaderLength: number, +): JwsProtectedHeader { + const headerBytes = decodeBase64Url(encodedProtectedHeader); + + if (headerBytes.length > maxHeaderLength) { + throw new JwsValidationError('Protected header exceeds maximum decoded length.'); + } + + let headerJson = ''; + try { + headerJson = utf8Decoder.decode(headerBytes); + } catch { + throw new JwsValidationError('Protected header must be valid UTF-8 JSON.'); + } + + const keys = extractTopLevelKeys(headerJson); + const seen = new Set(); + for (const key of keys) { + if (seen.has(key)) { + throw new JwsValidationError(`Duplicate protected header field '${key}'.`); + } + seen.add(key); + } + + let parsed: unknown; + try { + parsed = JSON.parse(headerJson); + } catch { + throw new JwsValidationError('Protected header must be valid JSON.'); + } + + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new JwsValidationError('Protected header must be a JSON object.'); + } + + const header = parsed as JwsProtectedHeader; + + if (typeof header.alg !== 'string' || header.alg.length === 0) { + throw new JwsValidationError("Protected header must include non-empty string 'alg'."); + } + + if (header.kid !== undefined && typeof header.kid !== 'string') { + throw new JwsValidationError("Protected header field 'kid' must be a string when present."); + } + + if (header.typ !== undefined && typeof header.typ !== 'string') { + throw new JwsValidationError("Protected header field 'typ' must be a string when present."); + } + + if (header.cty !== undefined && typeof header.cty !== 'string') { + throw new JwsValidationError("Protected header field 'cty' must be a string when present."); + } + + if (header.b64 === false) { + throw new JwsValidationError("Protected header field 'b64=false' is unsupported."); + } + + if (header.crit !== undefined) { + if (!Array.isArray(header.crit)) { + throw new JwsValidationError("Protected header field 'crit' must be an array of strings."); + } + + const critSeen = new Set(); + for (const item of header.crit) { + if (typeof item !== 'string' || item.length === 0) { + throw new JwsValidationError( + "Protected header field 'crit' must contain non-empty strings.", + ); + } + + if (critSeen.has(item)) { + throw new JwsValidationError( + "Protected header field 'crit' must not contain duplicate values.", + ); + } + critSeen.add(item); + + if (!(item in header)) { + throw new JwsValidationError( + `Protected header critical parameter '${item}' must be present in the protected header.`, + ); + } + + throw new JwsValidationError( + `Protected header critical parameter '${item}' is not supported by this implementation.`, + ); + } + } + + return header; +} + +export function serializeJwsCompact(segments: CompactJwsSegments): string { + const { protectedHeader, payload, signature } = segments; + + if (protectedHeader.length === 0 || signature.length === 0) { + throw new JwsFormatError( + 'Compact JWS protected header and signature segments must be non-empty.', + ); + } + + return `${protectedHeader}.${payload}.${signature}`; +} + +export function parseJwsCompact( + compact: string, + options: JwsCompactParseOptionsInput = {}, +): ParsedCompactJws { + if (typeof compact !== 'string') { + throw new JwsFormatError('Compact JWS must be a string.'); + } + + const resolvedOptions = resolveJwsCompactParseOptions(options); + + if (compact.length > resolvedOptions.maxCompactLength) { + throw new JwsValidationError('Compact JWS exceeds maximum length.'); + } + + const parts = compact.split('.'); + if (parts.length !== 3) { + throw new JwsFormatError('Compact JWS must contain exactly 3 segments.'); + } + + const [encodedProtectedHeader, encodedPayload, encodedSignature] = parts; + if (encodedProtectedHeader.length === 0 || encodedSignature.length === 0) { + throw new JwsFormatError( + 'Compact JWS protected header and signature segments must be non-empty.', + ); + } + + const protectedHeader = decodeProtectedHeader( + encodedProtectedHeader, + resolvedOptions.maxHeaderLength, + ); + + const payload = decodeBase64Url(encodedPayload); + if (payload.length > resolvedOptions.maxPayloadLength) { + throw new JwsValidationError('Compact JWS payload exceeds maximum decoded length.'); + } + + const signature = decodeBase64Url(encodedSignature); + if (signature.length > resolvedOptions.maxSignatureLength) { + throw new JwsValidationError('Compact JWS signature exceeds maximum decoded length.'); + } + + const signingInputText = `${encodedProtectedHeader}.${encodedPayload}`; + + return { + compact, + segments: { + protectedHeader: encodedProtectedHeader, + payload: encodedPayload, + signature: encodedSignature, + }, + protectedHeader, + encodedProtectedHeader, + encodedPayload, + payload, + signature, + signingInput: textEncoder.encode(signingInputText), + }; +} + +export function encodeProtectedHeader(protectedHeader: JwsProtectedHeader): string { + return encodeBase64Url(textEncoder.encode(JSON.stringify(protectedHeader))); } diff --git a/packages/pq-jws/ts/src/jws.ts b/packages/pq-jws/ts/src/jws.ts index 2dad934..ca278a1 100644 --- a/packages/pq-jws/ts/src/jws.ts +++ b/packages/pq-jws/ts/src/jws.ts @@ -1,12 +1,35 @@ +import { parseJwsCompact } from './compact'; import { JwsError } from './errors'; -import type { JwsVerifier, ParsedCompactJws, SignJwsCompactInput } from './types'; +import { JwsValidationError } from './errors'; +import type { + JwsVerifier, + ParsedCompactJws, + SignJwsCompactInput, + VerifyJwsCompactOptions, +} from './types'; export async function signJwsCompact(_input: SignJwsCompactInput): Promise { throw new JwsError('signJwsCompact is not implemented yet.'); } -export async function verifyJwsCompact(_compact: string, _verifier: JwsVerifier): Promise { - throw new JwsError('verifyJwsCompact is not implemented yet.'); +export async function verifyJwsCompact( + compact: string, + verifier: JwsVerifier, + options: VerifyJwsCompactOptions = {}, +): Promise { + const parsed = parseJwsCompact(compact, options.parseOptions); + const verificationResult = await verifier(parsed.signingInput, parsed.signature, { + protectedHeader: parsed.protectedHeader, + payload: parsed.payload, + encodedProtectedHeader: parsed.encodedProtectedHeader, + encodedPayload: parsed.encodedPayload, + }); + + if (typeof verificationResult !== 'boolean') { + throw new JwsValidationError('Verifier callback must resolve to a boolean value.'); + } + + return verificationResult; } export function decodePayloadText(_parsed: ParsedCompactJws): string { diff --git a/packages/pq-jws/ts/src/types.ts b/packages/pq-jws/ts/src/types.ts index d3eda73..8822a60 100644 --- a/packages/pq-jws/ts/src/types.ts +++ b/packages/pq-jws/ts/src/types.ts @@ -50,3 +50,16 @@ export interface SignJwsCompactInput { payload: Uint8Array | string; signer: JwsSigner; } + +export interface JwsCompactParseOptions { + maxCompactLength: number; + maxHeaderLength: number; + maxPayloadLength: number; + maxSignatureLength: number; +} + +export type JwsCompactParseOptionsInput = Partial; + +export interface VerifyJwsCompactOptions { + parseOptions?: JwsCompactParseOptionsInput; +} diff --git a/packages/pq-jws/ts/tests/base64url.test.ts b/packages/pq-jws/ts/tests/base64url.test.ts new file mode 100644 index 0000000..2ae7ff4 --- /dev/null +++ b/packages/pq-jws/ts/tests/base64url.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'bun:test'; +import { decodeBase64Url, encodeBase64Url } from '../src/base64url'; +import { JwsFormatError } from '../src/errors'; + +function makeBytes(length: number): Uint8Array { + const output = new Uint8Array(length); + for (let i = 0; i < length; i += 1) { + output[i] = i & 0xff; + } + return output; +} + +describe('base64url', () => { + it('round-trips various lengths', () => { + for (let i = 0; i <= 128; i += 1) { + const input = makeBytes(i); + const encoded = encodeBase64Url(input); + expect(encoded).not.toContain('='); + + const decoded = decodeBase64Url(encoded); + expect(Array.from(decoded)).toEqual(Array.from(input)); + } + }); + + it('rejects padded input', () => { + expect(() => decodeBase64Url('AA==')).toThrow(JwsFormatError); + expect(() => decodeBase64Url('AQI=')).toThrow(JwsFormatError); + }); + + it('rejects invalid characters', () => { + expect(() => decodeBase64Url('hello+world')).toThrow(JwsFormatError); + expect(() => decodeBase64Url('hello/world')).toThrow(JwsFormatError); + }); + + it('rejects invalid length remainder', () => { + expect(() => decodeBase64Url('A')).toThrow(JwsFormatError); + }); + + it('rejects non-zero trailing bits', () => { + expect(() => decodeBase64Url('AR')).toThrow(JwsFormatError); + expect(() => decodeBase64Url('AAB')).toThrow(JwsFormatError); + }); +}); diff --git a/packages/pq-jws/ts/tests/compact.test.ts b/packages/pq-jws/ts/tests/compact.test.ts new file mode 100644 index 0000000..427a4a0 --- /dev/null +++ b/packages/pq-jws/ts/tests/compact.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'bun:test'; +import { encodeBase64Url } from '../src/base64url'; +import { + DEFAULT_JWS_COMPACT_PARSE_OPTIONS, + parseJwsCompact, + resolveJwsCompactParseOptions, +} from '../src/compact'; +import { JwsFormatError, JwsValidationError } from '../src/errors'; +import { verifyJwsCompact } from '../src/jws'; + +const textEncoder = new TextEncoder(); + +function makeBytes(length: number): Uint8Array { + const output = new Uint8Array(length); + for (let i = 0; i < length; i += 1) { + output[i] = i & 0xff; + } + return output; +} + +function makeCompact(header: string, payloadBytes: Uint8Array, signatureBytes: Uint8Array): string { + return `${encodeBase64Url(textEncoder.encode(header))}.${encodeBase64Url(payloadBytes)}.${encodeBase64Url(signatureBytes)}`; +} + +describe('parseJwsCompact', () => { + it('parses valid compact JWS segments', () => { + const compact = makeCompact( + JSON.stringify({ alg: 'ML-DSA-44', typ: 'JWT' }), + textEncoder.encode('hello'), + new Uint8Array([1, 2, 3, 4]), + ); + + const parsed = parseJwsCompact(compact); + expect(parsed.protectedHeader.alg).toBe('ML-DSA-44'); + expect(parsed.protectedHeader.typ).toBe('JWT'); + expect(new TextDecoder().decode(parsed.payload)).toBe('hello'); + expect(Array.from(parsed.signature)).toEqual([1, 2, 3, 4]); + expect(new TextDecoder().decode(parsed.signingInput)).toBe( + `${parsed.encodedProtectedHeader}.${parsed.encodedPayload}`, + ); + }); + + it('enforces exactly 3 compact segments', () => { + expect(() => parseJwsCompact('a.b')).toThrow(JwsFormatError); + expect(() => parseJwsCompact('a.b.c.d')).toThrow(JwsFormatError); + }); + + it('rejects empty protected or signature segments', () => { + expect(() => parseJwsCompact('.a.b')).toThrow(JwsFormatError); + expect(() => parseJwsCompact('a.b.')).toThrow(JwsFormatError); + }); + + it('rejects compact segments with base64url padding', () => { + const payload = encodeBase64Url(textEncoder.encode('hello')); + const signature = encodeBase64Url(new Uint8Array([1, 2, 3])); + expect(() => parseJwsCompact(`eyJhbGciOiJNTC1EU0EtNDQifQ==.${payload}.${signature}`)).toThrow( + JwsFormatError, + ); + }); + + it('rejects oversized compact token', () => { + const compact = makeCompact(JSON.stringify({ alg: 'ML-DSA-44' }), makeBytes(64), makeBytes(64)); + expect(() => parseJwsCompact(compact, { maxCompactLength: 32 })).toThrow(JwsValidationError); + }); + + it('rejects oversized decoded header', () => { + const compact = makeCompact( + JSON.stringify({ alg: 'ML-DSA-44', kid: 'x'.repeat(20) }), + makeBytes(8), + makeBytes(8), + ); + expect(() => parseJwsCompact(compact, { maxHeaderLength: 8, maxCompactLength: 512 })).toThrow( + JwsValidationError, + ); + }); + + it('rejects oversized decoded payload', () => { + const compact = makeCompact(JSON.stringify({ alg: 'ML-DSA-44' }), makeBytes(40), makeBytes(8)); + expect(() => parseJwsCompact(compact, { maxPayloadLength: 8, maxCompactLength: 512 })).toThrow( + JwsValidationError, + ); + }); + + it('rejects oversized decoded signature', () => { + const compact = makeCompact(JSON.stringify({ alg: 'ML-DSA-44' }), makeBytes(8), makeBytes(40)); + expect(() => + parseJwsCompact(compact, { maxSignatureLength: 8, maxCompactLength: 512 }), + ).toThrow(JwsValidationError); + }); + + it('rejects duplicate protected header members, including escaped-key duplicates', () => { + const duplicateKeyHeader = makeCompact( + '{"alg":"ML-DSA-44","alg":"ML-DSA-65"}', + makeBytes(8), + makeBytes(8), + ); + expect(() => parseJwsCompact(duplicateKeyHeader)).toThrow(JwsValidationError); + + const escapedDuplicateHeader = makeCompact( + '{"alg":"ML-DSA-44","\\u0061lg":"ML-DSA-65"}', + makeBytes(8), + makeBytes(8), + ); + expect(() => parseJwsCompact(escapedDuplicateHeader)).toThrow(JwsValidationError); + }); + + it('rejects malformed UTF-8 protected header bytes', () => { + const malformedHeader = `${encodeBase64Url(new Uint8Array([0xc3, 0x28]))}.${encodeBase64Url(makeBytes(8))}.${encodeBase64Url(makeBytes(8))}`; + expect(() => parseJwsCompact(malformedHeader)).toThrow(JwsValidationError); + }); + + it('rejects b64=false unencoded payload mode', () => { + const compact = makeCompact( + JSON.stringify({ alg: 'ML-DSA-44', b64: false }), + makeBytes(8), + makeBytes(8), + ); + expect(() => parseJwsCompact(compact)).toThrow(JwsValidationError); + }); + + it('validates default bounds and override behavior', () => { + expect(DEFAULT_JWS_COMPACT_PARSE_OPTIONS).toEqual({ + maxCompactLength: 262_144, + maxHeaderLength: 16_384, + maxPayloadLength: 112_640, + maxSignatureLength: 65_536, + }); + + const overridden = resolveJwsCompactParseOptions({ + maxCompactLength: 10_000, + maxHeaderLength: 256, + maxPayloadLength: 4_000, + maxSignatureLength: 2_000, + }); + expect(overridden.maxCompactLength).toBe(10_000); + expect(overridden.maxHeaderLength).toBe(256); + expect(overridden.maxPayloadLength).toBe(4_000); + expect(overridden.maxSignatureLength).toBe(2_000); + }); + + it('ensures default bounds are internally consistent under base64url expansion', () => { + const maxHeaderEncoded = Math.ceil(DEFAULT_JWS_COMPACT_PARSE_OPTIONS.maxHeaderLength / 3) * 4; + const maxPayloadEncoded = Math.ceil(DEFAULT_JWS_COMPACT_PARSE_OPTIONS.maxPayloadLength / 3) * 4; + const maxSignatureEncoded = + Math.ceil(DEFAULT_JWS_COMPACT_PARSE_OPTIONS.maxSignatureLength / 3) * 4; + const maxCompact = maxHeaderEncoded + maxPayloadEncoded + maxSignatureEncoded + 2; + + expect(maxCompact).toBeLessThanOrEqual(DEFAULT_JWS_COMPACT_PARSE_OPTIONS.maxCompactLength); + }); + + it('enforces parse options through verifyJwsCompact call path', async () => { + const compact = makeCompact(JSON.stringify({ alg: 'ML-DSA-44' }), makeBytes(80), makeBytes(8)); + + await expect( + verifyJwsCompact(compact, async () => true, { + parseOptions: { + maxPayloadLength: 16, + maxCompactLength: 1_024, + }, + }), + ).rejects.toThrow(JwsValidationError); + }); +}); From f56a36ea9c72750a31ab890a45564afaefe8d366 Mon Sep 17 00:00:00 2001 From: Erhan Acet Date: Thu, 26 Feb 2026 14:22:00 +0300 Subject: [PATCH 2/2] Remove codex review trigger --- .claude/skills/implement-plan-linear/SKILL.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/.claude/skills/implement-plan-linear/SKILL.md b/.claude/skills/implement-plan-linear/SKILL.md index 2033c6d..d51792c 100644 --- a/.claude/skills/implement-plan-linear/SKILL.md +++ b/.claude/skills/implement-plan-linear/SKILL.md @@ -126,9 +126,6 @@ After manual confirmation for a phase, run these steps in order: 3. **Submit and publish the stack:** - `gt submit --publish` — pushes all branches in the stack and creates/updates PRs for each. -4. **Trigger automated review:** - - `gh pr comment --body "@codex review"` - Rules: - Always run `gt sync` before `gt create` — it is safe on stack branches and keeps the stack rebased on latest trunk.