diff --git a/packages/cursor-codec/package.json b/packages/cursor-codec/package.json new file mode 100644 index 0000000..9df466e --- /dev/null +++ b/packages/cursor-codec/package.json @@ -0,0 +1,23 @@ +{ + "name": "@guildpass/cursor-codec", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run" + }, + "devDependencies": { + "typescript": "^5.3.3", + "vitest": "^1.2.1" + } +} diff --git a/packages/cursor-codec/src/index.test.ts b/packages/cursor-codec/src/index.test.ts new file mode 100644 index 0000000..022b6dd --- /dev/null +++ b/packages/cursor-codec/src/index.test.ts @@ -0,0 +1,173 @@ +import { createHmac } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { createCursorCodec, type CursorPayload } from './index.js'; + +const SECRET = 'cursor-codec-test-secret'; +const PAYLOAD: CursorPayload = { + version: 1, + sortValue: '2026-09-04T12:00:00.000Z', + id: 'community_01J6Y9QJQY1T9Z4F4Q5SVD4Y9A', +}; + +function base64UrlJson(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString('base64url'); +} + +function sign(encodedPayload: string, secret = SECRET): string { + return createHmac('sha256', secret).update(encodedPayload, 'utf8').digest('base64url'); +} + +function signedCursor(payload: unknown, secret = SECRET): string { + const encodedPayload = base64UrlJson(payload); + return `${encodedPayload}.${sign(encodedPayload, secret)}`; +} + +describe('createCursorCodec', () => { + it('encodes and decodes valid payloads losslessly', () => { + const codec = createCursorCodec({ secret: SECRET }); + const cursor = codec.encode(PAYLOAD); + + expect(codec.decode(cursor)).toEqual({ valid: true, payload: PAYLOAD }); + }); + + it('serializes payloads deterministically', () => { + const codec = createCursorCodec({ secret: SECRET }); + + expect(codec.encode(PAYLOAD)).toBe(codec.encode({ ...PAYLOAD })); + }); + + it('encodes cursors using a URL-safe representation', () => { + const codec = createCursorCodec({ secret: SECRET }); + const cursor = codec.encode(PAYLOAD); + + expect(cursor).toMatch(/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/); + expect(cursor).not.toContain('='); + expect(cursor).not.toContain('+'); + expect(cursor).not.toContain('/'); + }); + + it('rejects client modification of the payload', () => { + const codec = createCursorCodec({ secret: SECRET }); + const cursor = codec.encode(PAYLOAD); + const [, signature] = cursor.split('.'); + const tamperedPayload = base64UrlJson({ ...PAYLOAD, id: 'community_tampered' }); + + expect(codec.decode(`${tamperedPayload}.${signature}`)).toEqual({ + valid: false, + code: 'SIGNATURE_MISMATCH', + }); + }); + + it('rejects client modification of the signature', () => { + const codec = createCursorCodec({ secret: SECRET }); + const cursor = codec.encode(PAYLOAD); + const [payload, signature] = cursor.split('.'); + const tamperedSignature = `${signature[0] === 'A' ? 'B' : 'A'}${signature.slice(1)}`; + + expect(codec.decode(`${payload}.${tamperedSignature}`)).toEqual({ + valid: false, + code: 'SIGNATURE_MISMATCH', + }); + }); + + it('rejects unsupported cursor versions after signature validation', () => { + const codec = createCursorCodec({ secret: SECRET }); + + expect(codec.decode(signedCursor({ ...PAYLOAD, version: 2 }))).toEqual({ + valid: false, + code: 'UNSUPPORTED_VERSION', + }); + }); + + it.each([ + '', + 'not-a-token', + 'too.many.parts', + 'bad+base64url.signature', + 'abcd.signature', + `${base64UrlJson(PAYLOAD)}.bad+signature`, + `${base64UrlJson(PAYLOAD)}.abcde`, + ])('handles malformed cursor input safely: %s', (cursor) => { + const codec = createCursorCodec({ secret: SECRET }); + + expect(() => codec.decode(cursor)).not.toThrow(); + expect(codec.decode(cursor).valid).toBe(false); + }); + + it('rejects malformed JSON payload encoding', () => { + const codec = createCursorCodec({ secret: SECRET }); + const encodedPayload = Buffer.from('{').toString('base64url'); + + expect(codec.decode(`${encodedPayload}.${sign(encodedPayload)}`)).toEqual({ + valid: false, + code: 'MALFORMED_CURSOR', + }); + }); + + it.each([ + { version: 1, sortValue: '', id: PAYLOAD.id }, + { version: 1, sortValue: PAYLOAD.sortValue, id: '' }, + { version: 1, sortValue: PAYLOAD.sortValue, id: PAYLOAD.id, extra: true }, + { version: 1, sortValue: 123, id: PAYLOAD.id }, + { version: 1, sortValue: PAYLOAD.sortValue, id: null }, + ])('rejects invalid signed payload shapes: %o', (payload) => { + const codec = createCursorCodec({ secret: SECRET }); + + expect(codec.decode(signedCursor(payload))).toEqual({ + valid: false, + code: 'INVALID_PAYLOAD', + }); + }); + + it('rejects oversized cursor input before decoding', () => { + const codec = createCursorCodec({ secret: SECRET, maxCursorLength: 20 }); + + expect(codec.decode('a'.repeat(21))).toEqual({ + valid: false, + code: 'CURSOR_TOO_LONG', + }); + }); + + it('enforces max cursor length while encoding', () => { + const codec = createCursorCodec({ secret: SECRET, maxCursorLength: 20 }); + + expect(() => codec.encode(PAYLOAD)).toThrow(RangeError); + }); + + it('requires secrets to be supplied externally', () => { + expect(() => createCursorCodec({ secret: '' })).toThrow(TypeError); + expect(() => createCursorCodec({ secret: new Uint8Array() })).toThrow(TypeError); + // @ts-expect-error exercising the runtime boundary + expect(() => createCursorCodec({ secret: undefined })).toThrow(TypeError); + }); + + it('supports Uint8Array secrets without embedding them in the cursor', () => { + const secret = Buffer.from(SECRET, 'utf8'); + const codec = createCursorCodec({ secret }); + const cursor = codec.encode(PAYLOAD); + + expect(codec.decode(cursor)).toEqual({ valid: true, payload: PAYLOAD }); + expect(cursor).not.toContain(SECRET); + expect(Buffer.from(cursor, 'base64url').toString('utf8')).not.toContain(SECRET); + }); + + it('rejects invalid caller-provided payloads at encode time', () => { + const codec = createCursorCodec({ secret: SECRET }); + + expect(() => codec.encode({ ...PAYLOAD, version: 2 as 1 })).toThrow(TypeError); + expect(() => codec.encode({ ...PAYLOAD, sortValue: '' })).toThrow(TypeError); + expect(() => codec.encode({ ...PAYLOAD, id: '' })).toThrow(TypeError); + // @ts-expect-error exercising the runtime boundary + expect(() => codec.encode(null)).toThrow(TypeError); + }); + + it('rejects invalid codec configuration', () => { + // @ts-expect-error exercising the runtime boundary + expect(() => createCursorCodec(null)).toThrow(TypeError); + expect(() => createCursorCodec({ secret: SECRET, maxCursorLength: 0 })).toThrow(RangeError); + expect(() => createCursorCodec({ secret: SECRET, maxCursorLength: 1.5 })).toThrow(RangeError); + expect(() => + createCursorCodec({ secret: SECRET, maxCursorLength: Number.MAX_SAFE_INTEGER + 1 }), + ).toThrow(RangeError); + }); +}); diff --git a/packages/cursor-codec/src/index.ts b/packages/cursor-codec/src/index.ts new file mode 100644 index 0000000..9b3d7f4 --- /dev/null +++ b/packages/cursor-codec/src/index.ts @@ -0,0 +1,225 @@ +import { createHmac, createSecretKey, timingSafeEqual } from 'node:crypto'; +import { types as utilTypes } from 'node:util'; + +const CURSOR_VERSION = 1; +const DEFAULT_MAX_CURSOR_LENGTH = 1024; +const HMAC_ALGORITHM = 'sha256'; +const HMAC_SHA256_BYTES = 32; +const TOKEN_SEPARATOR = '.'; +const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/; + +export interface CursorPayload { + readonly version: 1; + readonly sortValue: string; + readonly id: string; +} + +export type CursorDecodeFailureCode = + | 'CURSOR_TOO_LONG' + | 'MALFORMED_CURSOR' + | 'SIGNATURE_MISMATCH' + | 'UNSUPPORTED_VERSION' + | 'INVALID_PAYLOAD'; + +export type CursorDecodeResult = + | { readonly valid: true; readonly payload: CursorPayload } + | { + readonly valid: false; + readonly code: CursorDecodeFailureCode; + }; + +export interface CursorCodecConfig { + /** HMAC secret. String secrets are interpreted as UTF-8 bytes. */ + readonly secret: string | Uint8Array; + /** Maximum accepted encoded cursor length. Defaults to 1024 characters. */ + readonly maxCursorLength?: number; +} + +export interface CursorCodec { + encode(payload: CursorPayload): string; + decode(cursor: string): CursorDecodeResult; +} + +function failure(code: CursorDecodeFailureCode): CursorDecodeResult { + return { valid: false, code }; +} + +function isByteArray(value: unknown): value is Uint8Array { + return utilTypes.isUint8Array(value); +} + +function assertConfig(config: CursorCodecConfig): void { + if (typeof config !== 'object' || config === null) { + throw new TypeError('Cursor codec configuration must be an object'); + } + + if (typeof config.secret === 'string') { + if (config.secret.length === 0) { + throw new TypeError('Cursor codec secret must be non-empty'); + } + } else if (isByteArray(config.secret)) { + if (config.secret.byteLength === 0) { + throw new TypeError('Cursor codec secret must be non-empty'); + } + } else { + throw new TypeError('Cursor codec secret must be a string or Uint8Array'); + } + + if ( + config.maxCursorLength !== undefined && + (!Number.isSafeInteger(config.maxCursorLength) || config.maxCursorLength <= 0) + ) { + throw new RangeError('maxCursorLength must be a positive safe integer when provided'); + } +} + +function assertPayload(payload: CursorPayload): void { + if (typeof payload !== 'object' || payload === null) { + throw new TypeError('Cursor payload must be an object'); + } + + if (payload.version !== CURSOR_VERSION) { + throw new TypeError('Cursor payload version must be 1'); + } + + if (typeof payload.sortValue !== 'string' || payload.sortValue.length === 0) { + throw new TypeError('Cursor payload sortValue must be a non-empty string'); + } + + if (typeof payload.id !== 'string' || payload.id.length === 0) { + throw new TypeError('Cursor payload id must be a non-empty string'); + } +} + +function serializePayload(payload: CursorPayload): string { + return JSON.stringify({ + version: payload.version, + sortValue: payload.sortValue, + id: payload.id, + }); +} + +function encodeBase64Url(input: string | Uint8Array): string { + return Buffer.from(input).toString('base64url'); +} + +function decodeBase64Url(input: string): Buffer | null { + if ( + input.length === 0 || + input.length % 4 === 1 || + !BASE64URL_PATTERN.test(input) + ) { + return null; + } + + try { + return Buffer.from(input, 'base64url'); + } catch { + return null; + } +} + +function parsePayload(payloadBytes: Buffer): CursorDecodeResult { + let parsed: unknown; + + try { + parsed = JSON.parse(payloadBytes.toString('utf8')); + } catch { + return failure('MALFORMED_CURSOR'); + } + + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return failure('INVALID_PAYLOAD'); + } + + const record = parsed as Record; + if (record.version !== CURSOR_VERSION) { + return failure('UNSUPPORTED_VERSION'); + } + + if ( + Object.keys(record).length !== 3 || + typeof record.sortValue !== 'string' || + record.sortValue.length === 0 || + typeof record.id !== 'string' || + record.id.length === 0 + ) { + return failure('INVALID_PAYLOAD'); + } + + return { + valid: true, + payload: { + version: CURSOR_VERSION, + sortValue: record.sortValue, + id: record.id, + }, + }; +} + +export function createCursorCodec(config: CursorCodecConfig): CursorCodec { + assertConfig(config); + + const secretBytes = + typeof config.secret === 'string' + ? Buffer.from(config.secret, 'utf8') + : Buffer.from(config.secret); + const secretKey = createSecretKey(secretBytes); + const maxCursorLength = config.maxCursorLength ?? DEFAULT_MAX_CURSOR_LENGTH; + + const sign = (encodedPayload: string): Buffer => + createHmac(HMAC_ALGORITHM, secretKey).update(encodedPayload, 'utf8').digest(); + + const encode = (payload: CursorPayload): string => { + assertPayload(payload); + + const encodedPayload = encodeBase64Url(serializePayload(payload)); + const encodedSignature = encodeBase64Url(sign(encodedPayload)); + const cursor = `${encodedPayload}${TOKEN_SEPARATOR}${encodedSignature}`; + + if (cursor.length > maxCursorLength) { + throw new RangeError('Encoded cursor exceeds maxCursorLength'); + } + + return cursor; + }; + + const decode = (cursor: string): CursorDecodeResult => { + if (typeof cursor !== 'string' || cursor.length === 0) { + return failure('MALFORMED_CURSOR'); + } + + if (cursor.length > maxCursorLength) { + return failure('CURSOR_TOO_LONG'); + } + + const parts = cursor.split(TOKEN_SEPARATOR); + if (parts.length !== 2) { + return failure('MALFORMED_CURSOR'); + } + + const [encodedPayload, encodedSignature] = parts; + if (encodedPayload === undefined || encodedSignature === undefined) { + return failure('MALFORMED_CURSOR'); + } + + const payloadBytes = decodeBase64Url(encodedPayload); + const suppliedSignature = decodeBase64Url(encodedSignature); + if (payloadBytes === null || suppliedSignature === null) { + return failure('MALFORMED_CURSOR'); + } + + if (suppliedSignature.byteLength !== HMAC_SHA256_BYTES) { + return failure('SIGNATURE_MISMATCH'); + } + + const expectedSignature = sign(encodedPayload); + if (!timingSafeEqual(expectedSignature, suppliedSignature)) { + return failure('SIGNATURE_MISMATCH'); + } + + return parsePayload(payloadBytes); + }; + + return Object.freeze({ encode, decode }); +} diff --git a/packages/cursor-codec/tsconfig.json b/packages/cursor-codec/tsconfig.json new file mode 100644 index 0000000..8f24167 --- /dev/null +++ b/packages/cursor-codec/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e3cd53..9599b4a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,6 +49,15 @@ importers: packages/contribution-normalisation: {} + packages/cursor-codec: + devDependencies: + typescript: + specifier: ^5.3.3 + version: 5.9.3 + vitest: + specifier: ^1.2.1 + version: 1.6.1(@types/node@20.19.43)(supports-color@8.1.1) + packages/delegation-graph: {} packages/eligibility-rules: {}