diff --git a/packages/event-deduplication/package.json b/packages/event-deduplication/package.json new file mode 100644 index 0000000..276c0b2 --- /dev/null +++ b/packages/event-deduplication/package.json @@ -0,0 +1,19 @@ +{ + "name": "@guildpass/event-deduplication", + "version": "2.0.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": "node --test dist/**/*.test.js" + } +} diff --git a/packages/event-deduplication/src/index.test.ts b/packages/event-deduplication/src/index.test.ts new file mode 100644 index 0000000..100a324 --- /dev/null +++ b/packages/event-deduplication/src/index.test.ts @@ -0,0 +1,404 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + EventDeduplicationEngine, + InvalidFingerprintError, + type DeduplicationResult, +} from "./index.js"; + +describe("EventDeduplicationEngine", () => { + describe("Basic Deduplication", () => { + it("accepts a new fingerprint for processing", () => { + const engine = new EventDeduplicationEngine(); + const result = engine.checkAndRecord("event_123"); + + assert.equal(result.type, "first_seen"); + assert.equal(result.shouldProcess, true); + }); + + it("rejects a duplicate fingerprint inside retention window", () => { + const engine = new EventDeduplicationEngine(); + const fingerprint = "event_123"; + + const first = engine.checkAndRecord(fingerprint, { now: 1000 }); + assert.equal(first.type, "first_seen"); + + const duplicate = engine.checkAndRecord(fingerprint, { now: 1000 }); + assert.equal(duplicate.type, "duplicate"); + assert.equal(duplicate.shouldProcess, false); + }); + + it("accepts an expired fingerprint again after retention window", () => { + const engine = new EventDeduplicationEngine({ defaultRetentionMs: 1000 }); + const fingerprint = "event_123"; + + const first = engine.checkAndRecord(fingerprint, { now: 1000 }); + assert.equal(first.type, "first_seen"); + + // After expiry + const afterExpiry = engine.checkAndRecord(fingerprint, { now: 2500 }); + assert.equal(afterExpiry.type, "first_seen"); + assert.equal(afterExpiry.shouldProcess, true); + }); + + it("rejects invalid fingerprints", () => { + const engine = new EventDeduplicationEngine(); + + const emptyResult = engine.checkAndRecord(""); + assert.equal(emptyResult.type, "error"); + assert.equal(emptyResult.shouldProcess, false); + assert.ok(emptyResult.reason.includes("non-empty")); + + const whitespaceResult = engine.checkAndRecord(" "); + assert.equal(whitespaceResult.type, "error"); + assert.equal(whitespaceResult.shouldProcess, false); + }); + }); + + describe("Atomic Check-and-Record", () => { + it("atomically determines and records in single operation", () => { + const engine = new EventDeduplicationEngine(); + const fingerprint = "event_atomic"; + + const result = engine.checkAndRecord(fingerprint); + assert.equal(result.type, "first_seen"); + assert.equal(engine.getRecordCount(), 1); + }); + + it("concurrent attempts using same fingerprint result in at most one first-seen", async () => { + const engine = new EventDeduplicationEngine(); + const fingerprint = "event_concurrent"; + + // Simulate concurrent attempts + const results = await Promise.all([ + Promise.resolve(engine.checkAndRecord(fingerprint, { now: 1000 })), + Promise.resolve(engine.checkAndRecord(fingerprint, { now: 1000 })), + Promise.resolve(engine.checkAndRecord(fingerprint, { now: 1000 })), + Promise.resolve(engine.checkAndRecord(fingerprint, { now: 1000 })), + Promise.resolve(engine.checkAndRecord(fingerprint, { now: 1000 })), + ]); + + const firstSeenCount = results.filter((r) => r.type === "first_seen").length; + const duplicateCount = results.filter((r) => r.type === "duplicate").length; + + assert.equal(firstSeenCount, 1, "Exactly one should be first_seen"); + assert.equal(duplicateCount, 4, "All others should be duplicates"); + }); + }); + + describe("Expiry and Cleanup", () => { + it("cleans up expired entries automatically", () => { + const engine = new EventDeduplicationEngine({ defaultRetentionMs: 1000 }); + + engine.checkAndRecord("event_1", { now: 1000 }); // expires at 2000 + engine.checkAndRecord("event_2", { now: 1000 }); // expires at 2000 + engine.checkAndRecord("event_3", { now: 1000 }); // expires at 2000 + + assert.equal(engine.getRecordCount(), 3); + + // Trigger cleanup at t=2500 + const cleared = engine.clearExpired(2500); + assert.equal(cleared, 3); + assert.equal(engine.getRecordCount(), 0); + }); + + it("automatically cleans expired entries on checkAndRecord", () => { + const engine = new EventDeduplicationEngine({ defaultRetentionMs: 1000 }); + + engine.checkAndRecord("event_1", { now: 1000 }); + engine.checkAndRecord("event_2", { now: 1000 }); + + assert.equal(engine.getRecordCount(), 2); + + // New check at t=2500 should trigger cleanup + engine.checkAndRecord("event_3", { now: 2500 }); + + assert.equal(engine.getRecordCount(), 1); + }); + + it("treats expired fingerprints as new on subsequent checks", () => { + const engine = new EventDeduplicationEngine({ defaultRetentionMs: 1000 }); + const fingerprint = "event_expiry"; + + engine.checkAndRecord(fingerprint, { now: 1000 }); + + // Still valid + const stillValid = engine.check(fingerprint, 1500); + assert.equal(stillValid.type, "duplicate"); + + // Expired + const expired = engine.check(fingerprint, 2500); + assert.equal(expired.type, "first_seen"); + }); + }); + + describe("Memory Constraints and Eviction", () => { + it("enforces maximum retained entry limit", () => { + const engine = new EventDeduplicationEngine({ maxEntries: 3 }); + + engine.checkAndRecord("event_1", { now: 1000 }); + engine.checkAndRecord("event_2", { now: 1000 }); + engine.checkAndRecord("event_3", { now: 1000 }); + + assert.equal(engine.getRecordCount(), 3); + + // This should trigger eviction + engine.checkAndRecord("event_4", { now: 1000 }); + + assert.equal(engine.getRecordCount(), 3); + }); + + it("evicts oldest entry when capacity is reached (FIFO)", () => { + const engine = new EventDeduplicationEngine({ maxEntries: 2 }); + + const event1 = engine.checkAndRecord("event_1", { now: 1000 }); + assert.equal(event1.type, "first_seen"); + + const event2 = engine.checkAndRecord("event_2", { now: 1500 }); + assert.equal(event2.type, "first_seen"); + + // This should evict event_1 (oldest) + const event3 = engine.checkAndRecord("event_3", { now: 2000 }); + assert.equal(event3.type, "first_seen"); + + assert.equal(engine.getRecordCount(), 2); + + // event_1 should no longer exist + const check1 = engine.check("event_1", 2000); + assert.equal(check1.type, "first_seen"); + + // event_2 and event_3 should still exist + const check2 = engine.check("event_2", 2000); + assert.equal(check2.type, "duplicate"); + + const check3 = engine.check("event_3", 2000); + assert.equal(check3.type, "duplicate"); + }); + + it("prevents unbounded memory growth", () => { + const engine = new EventDeduplicationEngine({ maxEntries: 5 }); + + for (let i = 0; i < 100; i++) { + engine.checkAndRecord(`event_${i}`, { now: 1000 }); + } + + assert.equal(engine.getRecordCount(), 5); + }); + + it("has deterministic eviction behavior (oldest first)", () => { + const engine = new EventDeduplicationEngine({ maxEntries: 3 }); + + const events = ["a", "b", "c", "d", "e"]; + events.forEach((event, index) => { + engine.checkAndRecord(event, { now: 1000 + index * 100 }); + }); + + // Should have c, d, e (a and b evicted) + assert.equal(engine.getRecordCount(), 3); + assert.equal(engine.check("a", 1000).type, "first_seen"); + assert.equal(engine.check("b", 1000).type, "first_seen"); + assert.equal(engine.check("c", 1000).type, "duplicate"); + assert.equal(engine.check("d", 1000).type, "duplicate"); + assert.equal(engine.check("e", 1000).type, "duplicate"); + }); + }); + + describe("Configuration", () => { + it("uses configurable default retention window", () => { + const engine = new EventDeduplicationEngine({ defaultRetentionMs: 500 }); + const fingerprint = "event_config"; + + const first = engine.checkAndRecord(fingerprint, { now: 1000 }); + assert.equal(first.type, "first_seen"); + + const duplicate = engine.checkAndRecord(fingerprint, { now: 1200 }); + assert.equal(duplicate.type, "duplicate"); + + const expired = engine.checkAndRecord(fingerprint, { now: 1600 }); + assert.equal(expired.type, "first_seen"); + }); + + it("overrides default retention with custom retention", () => { + const engine = new EventDeduplicationEngine({ defaultRetentionMs: 10000 }); + const fingerprint = "event_custom"; + + const first = engine.checkAndRecord(fingerprint, { retentionMs: 500, now: 1000 }); + assert.equal(first.type, "first_seen"); + + const expired = engine.checkAndRecord(fingerprint, { retentionMs: 500, now: 1600 }); + assert.equal(expired.type, "first_seen"); + }); + }); + + describe("Time Injection for Testing", () => { + it("allows time to be injected for deterministic tests", () => { + let currentTime = 1000; + const engine = new EventDeduplicationEngine({ + now: () => currentTime, + defaultRetentionMs: 1000, + }); + + const fingerprint = "event_time"; + + const first = engine.checkAndRecord(fingerprint); + assert.equal(first.type, "first_seen"); + + // Still valid + currentTime = 1500; + const duplicate = engine.checkAndRecord(fingerprint); + assert.equal(duplicate.type, "duplicate"); + + // Expired + currentTime = 2500; + const expired = engine.checkAndRecord(fingerprint); + assert.equal(expired.type, "first_seen"); + }); + + it("supports controllable time without long sleeps", () => { + let currentTime = 1000; + const engine = new EventDeduplicationEngine({ + now: () => currentTime, + defaultRetentionMs: 500, + }); + + const fingerprint = "event_fast"; + + const first = engine.checkAndRecord(fingerprint, { now: currentTime }); + assert.equal(first.type, "first_seen"); + + currentTime = 1200; + const duplicate = engine.checkAndRecord(fingerprint, { now: currentTime }); + assert.equal(duplicate.type, "duplicate"); + + currentTime = 1600; + const expired = engine.checkAndRecord(fingerprint, { now: currentTime }); + assert.equal(expired.type, "first_seen"); + }); + }); + + describe("Check Operation (Non-Recording)", () => { + it("checks fingerprint without recording", () => { + const engine = new EventDeduplicationEngine(); + const fingerprint = "event_check"; + + const check1 = engine.check(fingerprint); + assert.equal(check1.type, "first_seen"); + assert.equal(engine.getRecordCount(), 0); + + engine.checkAndRecord(fingerprint); + const check2 = engine.check(fingerprint); + assert.equal(check2.type, "duplicate"); + }); + + it("handles expired fingerprints in check operation", () => { + const engine = new EventDeduplicationEngine({ defaultRetentionMs: 1000 }); + const fingerprint = "event_check_expiry"; + + engine.checkAndRecord(fingerprint, { now: 1000 }); + + const valid = engine.check(fingerprint, 1500); + assert.equal(valid.type, "duplicate"); + + const expired = engine.check(fingerprint, 2500); + assert.equal(expired.type, "first_seen"); + }); + + it("validates fingerprints in check operation", () => { + const engine = new EventDeduplicationEngine(); + + const result = engine.check(""); + assert.equal(result.type, "error"); + assert.equal(result.shouldProcess, false); + }); + }); + + describe("Edge Cases", () => { + it("handles rapid successive checks without issues", () => { + const engine = new EventDeduplicationEngine(); + const fingerprints: string[] = []; + + for (let i = 0; i < 100; i++) { + fingerprints.push(`event_${i}`); + } + + // All should be first_seen + const results = fingerprints.map((fp) => engine.checkAndRecord(fp)); + const firstSeenCount = results.filter((r) => r.type === "first_seen").length; + + assert.equal(firstSeenCount, 100); + }); + + it("handles special characters in fingerprints", () => { + const engine = new EventDeduplicationEngine(); + + const special = "event_with-special.chars_123"; + const result = engine.checkAndRecord(special); + + assert.equal(result.type, "first_seen"); + }); + + it("handles very long fingerprints", () => { + const engine = new EventDeduplicationEngine(); + const longFingerprint = "a".repeat(1000); + + const result = engine.checkAndRecord(longFingerprint); + assert.equal(result.type, "first_seen"); + }); + + it("clear operation removes all records", () => { + const engine = new EventDeduplicationEngine(); + + engine.checkAndRecord("event_1"); + engine.checkAndRecord("event_2"); + engine.checkAndRecord("event_3"); + + assert.equal(engine.getRecordCount(), 3); + + engine.clear(); + assert.equal(engine.getRecordCount(), 0); + + const result = engine.checkAndRecord("event_1"); + assert.equal(result.type, "first_seen"); + }); + + it("handles cleanup when no expired records exist", () => { + const engine = new EventDeduplicationEngine(); + + engine.checkAndRecord("event_1", { now: 1000 }); + engine.checkAndRecord("event_2", { now: 1000 }); + + const cleared = engine.clearExpired(1000); + assert.equal(cleared, 0); + assert.equal(engine.getRecordCount(), 2); + }); + }); + + describe("Error Handling", () => { + it("returns error result for null fingerprint", () => { + const engine = new EventDeduplicationEngine(); + + // @ts-expect-error - testing invalid input + const result = engine.checkAndRecord(null); + assert.equal(result.type, "error"); + assert.equal(result.shouldProcess, false); + }); + + it("returns error result for undefined fingerprint", () => { + const engine = new EventDeduplicationEngine(); + + // @ts-expect-error - testing invalid input + const result = engine.checkAndRecord(undefined); + assert.equal(result.type, "error"); + assert.equal(result.shouldProcess, false); + }); + + it("returns error result for non-string fingerprint", () => { + const engine = new EventDeduplicationEngine(); + + // @ts-expect-error - testing invalid input + const result = engine.checkAndRecord(123); + assert.equal(result.type, "error"); + assert.equal(result.shouldProcess, false); + }); + }); +}); diff --git a/packages/event-deduplication/src/index.ts b/packages/event-deduplication/src/index.ts new file mode 100644 index 0000000..806a7cf --- /dev/null +++ b/packages/event-deduplication/src/index.ts @@ -0,0 +1,179 @@ +export type DeduplicationResult = + | { type: "first_seen"; shouldProcess: true } + | { type: "duplicate"; shouldProcess: false } + | { type: "error"; shouldProcess: false; reason: string }; + +export interface EventRecord { + fingerprint: string; + firstSeenAt: number; + expiresAt: number; +} + +export interface CheckAndRecordOptions { + retentionMs?: number; + now?: number; +} + +export interface EventDeduplicationOptions { + defaultRetentionMs?: number; + maxEntries?: number; + now?: () => number; +} + +export class InvalidFingerprintError extends Error { + constructor(message = "Invalid fingerprint") { + super(message); + this.name = "InvalidFingerprintError"; + } +} + +export class EventDeduplicationEngine { + private readonly defaultRetentionMs: number; + private readonly maxEntries: number; + private readonly now: () => number; + private readonly records = new Map(); + + constructor(options: EventDeduplicationOptions = {}) { + this.defaultRetentionMs = options.defaultRetentionMs ?? 300000; // 5 minutes default + this.maxEntries = options.maxEntries ?? 10000; + this.now = options.now ?? Date.now; + } + + /** + * Validate fingerprint format + */ + private validateFingerprint(fingerprint: string): void { + if (!fingerprint || typeof fingerprint !== "string" || fingerprint.trim().length === 0) { + throw new InvalidFingerprintError("Fingerprint must be a non-empty string"); + } + } + + /** + * Atomically check if event has been seen and record if new + */ + checkAndRecord(fingerprint: string, options: CheckAndRecordOptions = {}): DeduplicationResult { + try { + this.validateFingerprint(fingerprint); + } catch (error) { + return { + type: "error", + shouldProcess: false, + reason: error instanceof Error ? error.message : "Invalid fingerprint", + }; + } + + const { retentionMs = this.defaultRetentionMs, now = this.now() } = options; + + // Clean up expired records first + this.cleanupExpired(now); + + // Check if already exists + const existing = this.records.get(fingerprint); + if (existing) { + if (existing.expiresAt <= now) { + // Expired - remove and treat as new + this.records.delete(fingerprint); + } else { + // Still valid - duplicate + return { type: "duplicate", shouldProcess: false }; + } + } + + // Enforce max entries limit + if (this.records.size >= this.maxEntries) { + this.evictOldest(); + } + + // Record new event + this.records.set(fingerprint, { + fingerprint, + firstSeenAt: now, + expiresAt: now + retentionMs, + }); + + return { type: "first_seen", shouldProcess: true }; + } + + /** + * Check if event has been seen without recording + */ + check(fingerprint: string, now = this.now()): DeduplicationResult { + try { + this.validateFingerprint(fingerprint); + } catch (error) { + return { + type: "error", + shouldProcess: false, + reason: error instanceof Error ? error.message : "Invalid fingerprint", + }; + } + + const existing = this.records.get(fingerprint); + if (!existing) { + return { type: "first_seen", shouldProcess: true }; + } + + if (existing.expiresAt <= now) { + // Expired - treat as new + return { type: "first_seen", shouldProcess: true }; + } + + // Still valid - duplicate + return { type: "duplicate", shouldProcess: false }; + } + + /** + * Clean up expired records + */ + private cleanupExpired(now: number): number { + let count = 0; + for (const [fingerprint, record] of this.records.entries()) { + if (record.expiresAt <= now) { + this.records.delete(fingerprint); + count++; + } + } + return count; + } + + /** + * Evict oldest entry when capacity is reached + * Uses FIFO eviction based on firstSeenAt timestamp + */ + private evictOldest(): void { + let oldestFingerprint: string | null = null; + let oldestFirstSeenAt = Infinity; + + for (const [fingerprint, record] of this.records.entries()) { + if (record.firstSeenAt < oldestFirstSeenAt) { + oldestFirstSeenAt = record.firstSeenAt; + oldestFingerprint = fingerprint; + } + } + + if (oldestFingerprint) { + this.records.delete(oldestFingerprint); + } + } + + /** + * Get current record count (useful for monitoring) + */ + getRecordCount(): number { + return this.records.size; + } + + /** + * Manually trigger cleanup of expired records + */ + clearExpired(now = this.now()): number { + return this.cleanupExpired(now); + } + + /** + * Clear all records (useful for testing or reset scenarios) + */ + clear(): void { + this.records.clear(); + } +} diff --git a/packages/event-deduplication/tsconfig.json b/packages/event-deduplication/tsconfig.json new file mode 100644 index 0000000..d8d9c3d --- /dev/null +++ b/packages/event-deduplication/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "baseUrl": "." + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/nonce-manager/package.json b/packages/nonce-manager/package.json new file mode 100644 index 0000000..664914f --- /dev/null +++ b/packages/nonce-manager/package.json @@ -0,0 +1,19 @@ +{ + "name": "@guildpass/nonce-manager", + "version": "2.0.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": "node --test dist/**/*.test.js" + } +} diff --git a/packages/nonce-manager/src/index.test.ts b/packages/nonce-manager/src/index.test.ts new file mode 100644 index 0000000..8183a92 --- /dev/null +++ b/packages/nonce-manager/src/index.test.ts @@ -0,0 +1,328 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { NonceManager } from "./index.js"; + +describe("NonceManager", () => { + describe("Nonce Generation", () => { + it("generates nonces using cryptographically secure randomness", () => { + const manager = new NonceManager(); + const nonce1 = manager.issue(); + const nonce2 = manager.issue(); + + assert.notEqual(nonce1, nonce2, "Nonces should be unique"); + assert.ok(nonce1.length > 0, "Nonce should not be empty"); + assert.ok(nonce2.length > 0, "Nonce should not be empty"); + }); + + it("generates nonces with sufficient configurable entropy", () => { + const manager16 = new NonceManager({ nonceByteLength: 16 }); + const manager32 = new NonceManager({ nonceByteLength: 32 }); + const manager64 = new NonceManager({ nonceByteLength: 64 }); + + const nonce16 = manager16.issue(); + const nonce32 = manager32.issue(); + const nonce64 = manager64.issue(); + + // Base64url encoding: 4 chars per 3 bytes, rounded up + assert.ok(nonce16.length >= 22, "16-byte nonce should have sufficient length"); + assert.ok(nonce32.length >= 43, "32-byte nonce should have sufficient length"); + assert.ok(nonce64.length >= 86, "64-byte nonce should have sufficient length"); + }); + + it("encodes nonces using URL-safe format", () => { + const manager = new NonceManager(); + const nonce = manager.issue(); + + // Base64url uses - and _ instead of + and /, and no padding + assert.ok(!nonce.includes("+"), "Nonce should not contain +"); + assert.ok(!nonce.includes("/"), "Nonce should not contain /"); + assert.ok(!nonce.includes("="), "Nonce should not contain padding"); + assert.ok(nonce.match(/^[A-Za-z0-9_-]+$/), "Nonce should only contain URL-safe characters"); + }); + }); + + describe("Issue and Validate", () => { + it("validates newly issued nonce successfully before expiry", () => { + const manager = new NonceManager(); + const nonce = manager.issue({ ttlMs: 5000, now: 1000 }); + + const result = manager.validate(nonce, 1000); + assert.equal(result.status, "valid"); + }); + + it("rejects unknown nonces", () => { + const manager = new NonceManager(); + const result = manager.validate("unknown_nonce"); + + assert.equal(result.status, "unknown"); + }); + + it("rejects expired nonces", () => { + const manager = new NonceManager(); + const nonce = manager.issue({ ttlMs: 1000, now: 1000 }); + + const result = manager.validate(nonce, 2000); + assert.equal(result.status, "expired"); + + // Verify expired record was cleaned up + const validateAgain = manager.validate(nonce, 2000); + assert.equal(validateAgain.status, "unknown"); + }); + }); + + describe("Consume Operations", () => { + it("consumes a valid nonce successfully", () => { + const manager = new NonceManager(); + const nonce = manager.issue({ ttlMs: 5000, now: 1000 }); + + const consumeResult = manager.consume(nonce, 1000); + assert.equal(consumeResult.status, "valid"); + }); + + it("prevents a consumed nonce from being consumed a second time", () => { + const manager = new NonceManager(); + const nonce = manager.issue({ ttlMs: 5000, now: 1000 }); + + const firstConsume = manager.consume(nonce, 1000); + assert.equal(firstConsume.status, "valid"); + + const secondConsume = manager.consume(nonce, 1000); + assert.equal(secondConsume.status, "consumed"); + }); + + it("validates consumed nonce as consumed without consuming it again", () => { + const manager = new NonceManager(); + const nonce = manager.issue({ ttlMs: 5000, now: 1000 }); + + manager.consume(nonce, 1000); + const validateResult = manager.validate(nonce, 1000); + + assert.equal(validateResult.status, "consumed"); + }); + + it("rejects consumed nonces", () => { + const manager = new NonceManager(); + const nonce = manager.issue({ ttlMs: 5000, now: 1000 }); + + manager.consume(nonce, 1000); + const consumeResult = manager.consume(nonce, 1000); + + assert.equal(consumeResult.status, "consumed"); + }); + + it("rejects unknown nonces during consume", () => { + const manager = new NonceManager(); + const consumeResult = manager.consume("unknown_nonce"); + + assert.equal(consumeResult.status, "unknown"); + }); + + it("rejects expired nonces during consume", () => { + const manager = new NonceManager(); + const nonce = manager.issue({ ttlMs: 1000, now: 1000 }); + + const consumeResult = manager.consume(nonce, 2000); + assert.equal(consumeResult.status, "expired"); + + // Verify expired record was cleaned up + const consumeAgain = manager.consume(nonce, 2000); + assert.equal(consumeAgain.status, "unknown"); + }); + }); + + describe("Concurrent Consumption", () => { + it("handles concurrent consume attempts for the same nonce atomically", async () => { + const manager = new NonceManager(); + const nonce = manager.issue({ ttlMs: 5000, now: 1000 }); + + // Simulate concurrent consume attempts + const results = await Promise.all([ + Promise.resolve(manager.consume(nonce, 1000)), + Promise.resolve(manager.consume(nonce, 1000)), + Promise.resolve(manager.consume(nonce, 1000)), + Promise.resolve(manager.consume(nonce, 1000)), + Promise.resolve(manager.consume(nonce, 1000)), + ]); + + const validCount = results.filter((r) => r.status === "valid").length; + const consumedCount = results.filter((r) => r.status === "consumed").length; + + assert.equal(validCount, 1, "Exactly one consume should succeed"); + assert.equal(consumedCount, 4, "All other attempts should return consumed status"); + }); + }); + + describe("Expiry and Cleanup", () => { + it("cleans up expired records automatically", () => { + const manager = new NonceManager({ maxRecords: 100 }); + + // Issue nonces at different times + manager.issue({ ttlMs: 1000, now: 1000 }); // expires at 2000 + manager.issue({ ttlMs: 2000, now: 1000 }); // expires at 3000 + manager.issue({ ttlMs: 500, now: 1000 }); // expires at 1500 + + assert.equal(manager.getRecordCount(), 3); + + // Trigger cleanup at t=1500 - only the 500ms TTL nonce should be expired + const cleared = manager.clearExpired(1500); + assert.equal(cleared, 1, "Should clear 1 expired record"); + assert.equal(manager.getRecordCount(), 2); + }); + + it("automatically cleans expired records when issuing new nonces", () => { + const manager = new NonceManager({ maxRecords: 100 }); + + manager.issue({ ttlMs: 1000, now: 1000 }); + manager.issue({ ttlMs: 500, now: 1000 }); + + assert.equal(manager.getRecordCount(), 2); + + // Issue new nonce at t=1500 - should trigger cleanup + manager.issue({ ttlMs: 1000, now: 1500 }); + + assert.equal(manager.getRecordCount(), 2, "Expired records should be cleaned"); + }); + }); + + describe("Memory Growth Constraints", () => { + it("enforces maximum number of retained records", () => { + const manager = new NonceManager({ maxRecords: 3 }); + + manager.issue({ now: 1000 }); + manager.issue({ now: 1000 }); + manager.issue({ now: 1000 }); + assert.equal(manager.getRecordCount(), 3); + + // This should remove the oldest record + manager.issue({ now: 1000 }); + assert.equal(manager.getRecordCount(), 3); + }); + + it("removes oldest record when at capacity", () => { + const manager = new NonceManager({ maxRecords: 2 }); + + const nonce1 = manager.issue({ now: 1000 }); + const nonce2 = manager.issue({ now: 1000 }); + + // Both should be valid + assert.equal(manager.validate(nonce1, 1000).status, "valid"); + assert.equal(manager.validate(nonce2, 1000).status, "valid"); + + // Issue third nonce - should remove oldest (nonce1) + const nonce3 = manager.issue({ now: 1000 }); + + assert.equal(manager.getRecordCount(), 2); + assert.equal(manager.validate(nonce1, 1000).status, "unknown"); + assert.equal(manager.validate(nonce2, 1000).status, "valid"); + assert.equal(manager.validate(nonce3, 1000).status, "valid"); + }); + + it("prevents unbounded memory growth", () => { + const manager = new NonceManager({ maxRecords: 5 }); + + for (let i = 0; i < 100; i++) { + manager.issue({ now: 1000 }); + } + + assert.equal(manager.getRecordCount(), 5, "Record count should not exceed maxRecords"); + }); + }); + + describe("Time Injection for Testing", () => { + it("allows time to be injected for deterministic tests", () => { + let currentTime = 1000; + const manager = new NonceManager({ + now: () => currentTime, + defaultTtlMs: 1000, + }); + + const nonce = manager.issue(); + assert.equal(manager.validate(nonce).status, "valid"); + + // Advance time to expiry + currentTime = 2000; + assert.equal(manager.validate(nonce).status, "expired"); + }); + + it("supports controllable time in tests without long sleeps", () => { + let currentTime = 1000; + const manager = new NonceManager({ + now: () => currentTime, + defaultTtlMs: 500, + }); + + const nonce = manager.issue({ now: currentTime }); + assert.equal(manager.validate(nonce, currentTime).status, "valid"); + + // Advance time but still valid + currentTime = 1200; + assert.equal(manager.validate(nonce, currentTime).status, "valid"); + + // Advance time past expiry + currentTime = 1600; + assert.equal(manager.validate(nonce, currentTime).status, "expired"); + }); + }); + + describe("Configuration", () => { + it("uses configurable default TTL", () => { + const manager = new NonceManager({ defaultTtlMs: 1000 }); + const nonce = manager.issue({ now: 1000 }); + + assert.equal(manager.validate(nonce, 1500).status, "valid"); + assert.equal(manager.validate(nonce, 2500).status, "expired"); + }); + + it("overrides default TTL with custom TTL", () => { + const manager = new NonceManager({ defaultTtlMs: 10000 }); + const nonce = manager.issue({ ttlMs: 500, now: 1000 }); + + assert.equal(manager.validate(nonce, 1200).status, "valid"); + assert.equal(manager.validate(nonce, 1600).status, "expired"); + }); + + it("uses configurable nonce byte length", () => { + const manager = new NonceManager({ nonceByteLength: 16 }); + const nonce = manager.issue(); + + // 16 bytes -> ~22 chars in base64url + assert.ok(nonce.length >= 22); + assert.ok(nonce.length <= 24); + }); + }); + + describe("Edge Cases", () => { + it("handles rapid successive issues without issues", () => { + const manager = new NonceManager(); + const nonces: string[] = []; + + for (let i = 0; i < 100; i++) { + nonces.push(manager.issue()); + } + + // All nonces should be unique + const uniqueNonces = new Set(nonces); + assert.equal(uniqueNonces.size, 100); + }); + + it("validates after consume returns consumed status", () => { + const manager = new NonceManager(); + const nonce = manager.issue(); + + manager.consume(nonce); + const validateResult = manager.validate(nonce); + + assert.equal(validateResult.status, "consumed"); + }); + + it("handles cleanup when no expired records exist", () => { + const manager = new NonceManager(); + manager.issue({ ttlMs: 10000, now: 1000 }); + manager.issue({ ttlMs: 10000, now: 1000 }); + + const cleared = manager.clearExpired(1000); + assert.equal(cleared, 0); + assert.equal(manager.getRecordCount(), 2); + }); + }); +}); diff --git a/packages/nonce-manager/src/index.ts b/packages/nonce-manager/src/index.ts new file mode 100644 index 0000000..16e58d7 --- /dev/null +++ b/packages/nonce-manager/src/index.ts @@ -0,0 +1,178 @@ +import { randomBytes } from "node:crypto"; + +export type NonceStatus = "valid" | "consumed" | "expired" | "unknown"; + +export interface NonceRecord { + nonce: string; + createdAt: number; + expiresAt: number; + consumed: boolean; + consumedAt?: number; +} + +export interface IssueOptions { + ttlMs?: number; + now?: number; +} + +export interface ValidateResult { + status: NonceStatus; +} + +export interface ConsumeResult { + status: NonceStatus; +} + +export interface NonceManagerOptions { + defaultTtlMs?: number; + nonceByteLength?: number; + maxRecords?: number; + now?: () => number; +} + +export class NonceManager { + private readonly defaultTtlMs: number; + private readonly nonceByteLength: number; + private readonly maxRecords: number; + private readonly now: () => number; + private readonly records = new Map(); + + constructor(options: NonceManagerOptions = {}) { + this.defaultTtlMs = options.defaultTtlMs ?? 300000; // 5 minutes default + this.nonceByteLength = options.nonceByteLength ?? 32; // 256 bits of entropy + this.maxRecords = options.maxRecords ?? 10000; + this.now = options.now ?? Date.now; + } + + /** + * Generate a cryptographically secure random nonce + */ + private generateNonce(): string { + const bytes = randomBytes(this.nonceByteLength); + return bytes.toString("base64url"); + } + + /** + * Issue a new nonce with optional TTL + */ + issue(options: IssueOptions = {}): string { + const { ttlMs = this.defaultTtlMs, now = this.now() } = options; + + // Enforce max records limit by cleaning expired records first + this.cleanupExpired(now); + + // If still at capacity, remove oldest record + if (this.records.size >= this.maxRecords) { + this.removeOldestRecord(); + } + + const nonce = this.generateNonce(); + const expiresAt = now + ttlMs; + + this.records.set(nonce, { + nonce, + createdAt: now, + expiresAt, + consumed: false, + }); + + return nonce; + } + + /** + * Validate a nonce without consuming it + */ + validate(nonce: string, now = this.now()): ValidateResult { + const record = this.records.get(nonce); + + if (!record) { + return { status: "unknown" }; + } + + if (record.expiresAt <= now) { + // Clean up expired record + this.records.delete(nonce); + return { status: "expired" }; + } + + if (record.consumed) { + return { status: "consumed" }; + } + + return { status: "valid" }; + } + + /** + * Consume a nonce atomically - can only be consumed once + */ + consume(nonce: string, now = this.now()): ConsumeResult { + const record = this.records.get(nonce); + + if (!record) { + return { status: "unknown" }; + } + + if (record.expiresAt <= now) { + // Clean up expired record + this.records.delete(nonce); + return { status: "expired" }; + } + + if (record.consumed) { + return { status: "consumed" }; + } + + // Atomic consume operation + record.consumed = true; + record.consumedAt = now; + + return { status: "valid" }; + } + + /** + * Clean up expired records + */ + private cleanupExpired(now: number): number { + let count = 0; + for (const [nonce, record] of this.records.entries()) { + if (record.expiresAt <= now) { + this.records.delete(nonce); + count++; + } + } + return count; + } + + /** + * Remove the oldest record to enforce memory constraints + */ + private removeOldestRecord(): void { + let oldestNonce: string | null = null; + let oldestCreatedAt = Infinity; + + for (const [nonce, record] of this.records.entries()) { + if (record.createdAt < oldestCreatedAt) { + oldestCreatedAt = record.createdAt; + oldestNonce = nonce; + } + } + + if (oldestNonce) { + this.records.delete(oldestNonce); + } + } + + /** + * Get current record count (useful for monitoring) + */ + getRecordCount(): number { + return this.records.size; + } + + /** + * Manually trigger cleanup of expired records + */ + clearExpired(now = this.now()): number { + return this.cleanupExpired(now); + } +} diff --git a/packages/nonce-manager/tsconfig.json b/packages/nonce-manager/tsconfig.json new file mode 100644 index 0000000..d8d9c3d --- /dev/null +++ b/packages/nonce-manager/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "baseUrl": "." + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/permission-inheritance/package.json b/packages/permission-inheritance/package.json new file mode 100644 index 0000000..89a0976 --- /dev/null +++ b/packages/permission-inheritance/package.json @@ -0,0 +1,19 @@ +{ + "name": "@guildpass/permission-inheritance", + "version": "2.0.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": "node --test dist/**/*.test.js" + } +} diff --git a/packages/permission-inheritance/src/index.test.ts b/packages/permission-inheritance/src/index.test.ts new file mode 100644 index 0000000..078789c --- /dev/null +++ b/packages/permission-inheritance/src/index.test.ts @@ -0,0 +1,514 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + PermissionInheritanceResolver, + CycleDetectedError, + MissingRoleError, + DepthLimitExceededError, + GraphSizeLimitExceededError, + InvalidRoleError, + type RoleNode, +} from "./index.js"; + +describe("PermissionInheritanceResolver", () => { + describe("Direct Permissions", () => { + it("resolves direct permissions correctly", () => { + const resolver = new PermissionInheritanceResolver(); + const roles: RoleNode[] = [ + { id: "admin", permissions: ["read", "write", "delete"], inherits: [] }, + ]; + + const result = resolver.resolve(roles, "admin"); + + assert.equal(result.roleId, "admin"); + assert.deepEqual(result.effectivePermissions, ["delete", "read", "write"]); + assert.deepEqual(result.inheritancePath, ["admin"]); + }); + + it("handles role with no permissions", () => { + const resolver = new PermissionInheritanceResolver(); + const roles: RoleNode[] = [{ id: "guest", permissions: [], inherits: [] }]; + + const result = resolver.resolve(roles, "guest"); + + assert.equal(result.roleId, "guest"); + assert.deepEqual(result.effectivePermissions, []); + assert.deepEqual(result.inheritancePath, ["guest"]); + }); + }); + + describe("Single-Level Inheritance", () => { + it("resolves single-level inheritance correctly", () => { + const resolver = new PermissionInheritanceResolver(); + const roles: RoleNode[] = [ + { id: "admin", permissions: ["delete"], inherits: [] }, + { id: "moderator", permissions: ["edit"], inherits: ["admin"] }, + ]; + + const result = resolver.resolve(roles, "moderator"); + + assert.equal(result.roleId, "moderator"); + assert.deepEqual(result.effectivePermissions, ["delete", "edit"]); + assert.deepEqual(result.inheritancePath, ["moderator", "admin"]); + }); + + it("includes both direct and inherited permissions", () => { + const resolver = new PermissionInheritanceResolver(); + const roles: RoleNode[] = [ + { id: "base", permissions: ["read"], inherits: [] }, + { id: "extended", permissions: ["write"], inherits: ["base"] }, + ]; + + const result = resolver.resolve(roles, "extended"); + + assert.deepEqual(result.effectivePermissions, ["read", "write"]); + }); + }); + + describe("Multi-Level Inheritance", () => { + it("resolves multi-level inheritance correctly", () => { + const resolver = new PermissionInheritanceResolver(); + const roles: RoleNode[] = [ + { id: "level1", permissions: ["perm1"], inherits: [] }, + { id: "level2", permissions: ["perm2"], inherits: ["level1"] }, + { id: "level3", permissions: ["perm3"], inherits: ["level2"] }, + ]; + + const result = resolver.resolve(roles, "level3"); + + assert.deepEqual(result.effectivePermissions, ["perm1", "perm2", "perm3"]); + assert.deepEqual(result.inheritancePath, ["level3", "level2", "level1"]); + }); + + it("handles deep inheritance chains", () => { + const resolver = new PermissionInheritanceResolver(); + const roles: RoleNode[] = [ + { id: "r1", permissions: ["p1"], inherits: [] }, + { id: "r2", permissions: ["p2"], inherits: ["r1"] }, + { id: "r3", permissions: ["p3"], inherits: ["r2"] }, + { id: "r4", permissions: ["p4"], inherits: ["r3"] }, + { id: "r5", permissions: ["p5"], inherits: ["r4"] }, + ]; + + const result = resolver.resolve(roles, "r5"); + + assert.deepEqual(result.effectivePermissions, ["p1", "p2", "p3", "p4", "p5"]); + }); + }); + + describe("Multiple Inheritance", () => { + it("resolves multiple inheritance correctly", () => { + const resolver = new PermissionInheritanceResolver(); + const roles: RoleNode[] = [ + { id: "reader", permissions: ["read"], inherits: [] }, + { id: "writer", permissions: ["write"], inherits: [] }, + { id: "admin", permissions: ["delete"], inherits: ["reader", "writer"] }, + ]; + + const result = resolver.resolve(roles, "admin"); + + assert.deepEqual(result.effectivePermissions, ["delete", "read", "write"]); + }); + + it("handles diamond inheritance pattern", () => { + const resolver = new PermissionInheritanceResolver(); + const roles: RoleNode[] = [ + { id: "base", permissions: ["read"], inherits: [] }, + { id: "role_a", permissions: ["write"], inherits: ["base"] }, + { id: "role_b", permissions: ["delete"], inherits: ["base"] }, + { id: "combined", permissions: ["admin"], inherits: ["role_a", "role_b"] }, + ]; + + const result = resolver.resolve(roles, "combined"); + + // Should include all permissions from all paths, but base only once + assert.deepEqual(result.effectivePermissions, ["admin", "delete", "read", "write"]); + }); + + it("deduplicates permissions from multiple paths", () => { + const resolver = new PermissionInheritanceResolver(); + const roles: RoleNode[] = [ + { id: "base", permissions: ["read", "write"], inherits: [] }, + { id: "role_a", permissions: ["read"], inherits: ["base"] }, + { id: "role_b", permissions: ["write"], inherits: ["base"] }, + { id: "combined", permissions: [], inherits: ["role_a", "role_b"] }, + ]; + + const result = resolver.resolve(roles, "combined"); + + // read and write should appear only once despite being inherited via multiple paths + assert.deepEqual(result.effectivePermissions, ["read", "write"]); + }); + }); + + describe("Permission Deduplication", () => { + it("removes duplicate permissions from same role", () => { + const resolver = new PermissionInheritanceResolver(); + const roles: RoleNode[] = [ + { id: "role", permissions: ["read", "read", "write"], inherits: [] }, + ]; + + const result = resolver.resolve(roles, "role"); + + assert.deepEqual(result.effectivePermissions, ["read", "write"]); + }); + + it("removes duplicate permissions across inheritance chain", () => { + const resolver = new PermissionInheritanceResolver(); + const roles: RoleNode[] = [ + { id: "base", permissions: ["read"], inherits: [] }, + { id: "child", permissions: ["read", "write"], inherits: ["base"] }, + ]; + + const result = resolver.resolve(roles, "child"); + + assert.deepEqual(result.effectivePermissions, ["read", "write"]); + }); + }); + + describe("Cycle Detection", () => { + it("detects simple cycle", () => { + const resolver = new PermissionInheritanceResolver(); + const roles: RoleNode[] = [ + { id: "a", permissions: [], inherits: ["b"] }, + { id: "b", permissions: [], inherits: ["a"] }, + ]; + + assert.throws(() => resolver.resolve(roles, "a"), CycleDetectedError); + }); + + it("detects complex cycle", () => { + const resolver = new PermissionInheritanceResolver(); + const roles: RoleNode[] = [ + { id: "a", permissions: [], inherits: ["b"] }, + { id: "b", permissions: [], inherits: ["c"] }, + { id: "c", permissions: [], inherits: ["a"] }, + ]; + + assert.throws(() => resolver.resolve(roles, "a"), CycleDetectedError); + }); + + it("detects self-referential cycle", () => { + const resolver = new PermissionInheritanceResolver(); + const roles: RoleNode[] = [ + { id: "a", permissions: [], inherits: ["a"] }, + ]; + + assert.throws(() => resolver.resolve(roles, "a"), CycleDetectedError); + }); + + it("provides cycle information in error", () => { + const resolver = new PermissionInheritanceResolver(); + const roles: RoleNode[] = [ + { id: "a", permissions: [], inherits: ["b"] }, + { id: "b", permissions: [], inherits: ["a"] }, + ]; + + try { + resolver.resolve(roles, "a"); + assert.fail("Should have thrown CycleDetectedError"); + } catch (error) { + assert.ok(error instanceof CycleDetectedError); + assert.ok((error as CycleDetectedError).cycle.includes("a")); + assert.ok((error as CycleDetectedError).cycle.includes("b")); + } + }); + }); + + describe("Missing Role Detection", () => { + it("rejects references to unknown parent roles", () => { + const resolver = new PermissionInheritanceResolver(); + const roles: RoleNode[] = [ + { id: "child", permissions: [], inherits: ["missing_parent"] }, + ]; + + assert.throws(() => resolver.resolve(roles, "child"), MissingRoleError); + }); + + it("provides missing role information in error", () => { + const resolver = new PermissionInheritanceResolver(); + const roles: RoleNode[] = [ + { id: "child", permissions: [], inherits: ["missing_parent"] }, + ]; + + try { + resolver.resolve(roles, "child"); + assert.fail("Should have thrown MissingRoleError"); + } catch (error) { + assert.ok(error instanceof MissingRoleError); + assert.equal((error as MissingRoleError).roleId, "child"); + assert.equal((error as MissingRoleError).missingParentId, "missing_parent"); + } + }); + + it("rejects resolution of non-existent role", () => { + const resolver = new PermissionInheritanceResolver(); + const roles: RoleNode[] = [ + { id: "existing", permissions: [], inherits: [] }, + ]; + + assert.throws(() => resolver.resolve(roles, "nonexistent"), InvalidRoleError); + }); + }); + + describe("Deterministic Ordering", () => { + it("returns permissions in deterministic alphabetical order", () => { + const resolver = new PermissionInheritanceResolver(); + const roles: RoleNode[] = [ + { id: "role", permissions: ["zebra", "apple", "banana"], inherits: [] }, + ]; + + const result = resolver.resolve(roles, "role"); + + assert.deepEqual(result.effectivePermissions, ["apple", "banana", "zebra"]); + }); + + it("maintains consistent ordering across multiple resolutions", () => { + const resolver = new PermissionInheritanceResolver(); + const roles: RoleNode[] = [ + { id: "base", permissions: ["z"], inherits: [] }, + { id: "mid", permissions: ["y"], inherits: ["base"] }, + { id: "top", permissions: ["x"], inherits: ["mid"] }, + ]; + + const result1 = resolver.resolve(roles, "top"); + const result2 = resolver.resolve(roles, "top"); + + assert.deepEqual(result1.effectivePermissions, result2.effectivePermissions); + }); + }); + + describe("Depth Limits", () => { + it("enforces configurable depth limit", () => { + const resolver = new PermissionInheritanceResolver(); + const roles: RoleNode[] = [ + { id: "r1", permissions: [], inherits: [] }, + { id: "r2", permissions: [], inherits: ["r1"] }, + { id: "r3", permissions: [], inherits: ["r2"] }, + { id: "r4", permissions: [], inherits: ["r3"] }, + ]; + + assert.throws( + () => resolver.resolve(roles, "r4", { maxDepth: 2 }), + DepthLimitExceededError + ); + }); + + it("allows resolutions within depth limit", () => { + const resolver = new PermissionInheritanceResolver(); + const roles: RoleNode[] = [ + { id: "r1", permissions: ["p1"], inherits: [] }, + { id: "r2", permissions: ["p2"], inherits: ["r1"] }, + { id: "r3", permissions: ["p3"], inherits: ["r2"] }, + ]; + + const result = resolver.resolve(roles, "r3", { maxDepth: 5 }); + + assert.deepEqual(result.effectivePermissions, ["p1", "p2", "p3"]); + }); + + it("provides depth limit information in error", () => { + const resolver = new PermissionInheritanceResolver(); + const roles: RoleNode[] = [ + { id: "r1", permissions: [], inherits: [] }, + { id: "r2", permissions: [], inherits: ["r1"] }, + { id: "r3", permissions: [], inherits: ["r2"] }, + ]; + + try { + resolver.resolve(roles, "r3", { maxDepth: 1 }); + assert.fail("Should have thrown DepthLimitExceededError"); + } catch (error) { + assert.ok(error instanceof DepthLimitExceededError); + assert.equal((error as DepthLimitExceededError).maxDepth, 1); + } + }); + }); + + describe("Graph Size Limits", () => { + it("enforces configurable graph size limit", () => { + const resolver = new PermissionInheritanceResolver({ defaultMaxRoles: 2 }); + const roles: RoleNode[] = [ + { id: "r1", permissions: [], inherits: [] }, + { id: "r2", permissions: [], inherits: [] }, + { id: "r3", permissions: [], inherits: [] }, + ]; + + assert.throws(() => resolver.resolve(roles, "r1"), GraphSizeLimitExceededError); + }); + + it("allows resolutions within graph size limit", () => { + const resolver = new PermissionInheritanceResolver({ defaultMaxRoles: 5 }); + const roles: RoleNode[] = [ + { id: "r1", permissions: ["p1"], inherits: [] }, + { id: "r2", permissions: ["p2"], inherits: [] }, + ]; + + const result = resolver.resolve(roles, "r1"); + + assert.deepEqual(result.effectivePermissions, ["p1"]); + }); + + it("respects per-call maxRoles option", () => { + const resolver = new PermissionInheritanceResolver(); + const roles: RoleNode[] = [ + { id: "r1", permissions: [], inherits: [] }, + { id: "r2", permissions: [], inherits: [] }, + { id: "r3", permissions: [], inherits: [] }, + ]; + + assert.throws( + () => resolver.resolve(roles, "r1", { maxRoles: 2 }), + GraphSizeLimitExceededError + ); + }); + }); + + describe("Role Validation", () => { + it("rejects roles with empty ID", () => { + const resolver = new PermissionInheritanceResolver(); + const roles: RoleNode[] = [ + { id: "", permissions: [], inherits: [] }, + ]; + + assert.throws(() => resolver.resolve(roles, ""), InvalidRoleError); + }); + + it("rejects roles with non-array permissions", () => { + const resolver = new PermissionInheritanceResolver(); + const roles: RoleNode[] = [ + { id: "role", permissions: "not-an-array" as unknown as string[], inherits: [] }, + ]; + + assert.throws(() => resolver.resolve(roles, "role"), InvalidRoleError); + }); + + it("rejects roles with non-array inherits", () => { + const resolver = new PermissionInheritanceResolver(); + const roles: RoleNode[] = [ + { id: "role", permissions: [], inherits: "not-an-array" as unknown as string[] }, + ]; + + assert.throws(() => resolver.resolve(roles, "role"), InvalidRoleError); + }); + + it("rejects non-string permissions", () => { + const resolver = new PermissionInheritanceResolver(); + const roles: RoleNode[] = [ + { id: "role", permissions: [123 as unknown as string], inherits: [] }, + ]; + + assert.throws(() => resolver.resolve(roles, "role"), InvalidRoleError); + }); + + it("rejects non-string inherited role IDs", () => { + const resolver = new PermissionInheritanceResolver(); + const roles: RoleNode[] = [ + { id: "role", permissions: [], inherits: [123 as unknown as string] }, + ]; + + assert.throws(() => resolver.resolve(roles, "role"), InvalidRoleError); + }); + }); + + describe("Multiple Role Resolution", () => { + it("resolves permissions for multiple roles", () => { + const resolver = new PermissionInheritanceResolver(); + const roles: RoleNode[] = [ + { id: "admin", permissions: ["delete"], inherits: [] }, + { id: "user", permissions: ["read"], inherits: [] }, + ]; + + const results = resolver.resolveMultiple(roles, ["admin", "user"]); + + assert.equal(results.size, 2); + assert.deepEqual(results.get("admin")?.effectivePermissions, ["delete"]); + assert.deepEqual(results.get("user")?.effectivePermissions, ["read"]); + }); + + it("propagates errors in multiple resolution", () => { + const resolver = new PermissionInheritanceResolver(); + const roles: RoleNode[] = [ + { id: "admin", permissions: [], inherits: ["missing"] }, + ]; + + assert.throws(() => resolver.resolveMultiple(roles, ["admin"]), MissingRoleError); + }); + }); + + describe("Map Input Support", () => { + it("accepts Map input for roles", () => { + const resolver = new PermissionInheritanceResolver(); + const roles = new Map([ + ["admin", { id: "admin", permissions: ["delete"], inherits: [] }], + ]); + + const result = resolver.resolve(roles, "admin"); + + assert.deepEqual(result.effectivePermissions, ["delete"]); + }); + + it("handles Map with complex inheritance", () => { + const resolver = new PermissionInheritanceResolver(); + const roles = new Map([ + ["base", { id: "base", permissions: ["read"], inherits: [] }], + ["child", { id: "child", permissions: ["write"], inherits: ["base"] }], + ]); + + const result = resolver.resolve(roles, "child"); + + assert.deepEqual(result.effectivePermissions, ["read", "write"]); + }); + }); + + describe("Edge Cases", () => { + it("handles empty role array", () => { + const resolver = new PermissionInheritanceResolver(); + const roles: RoleNode[] = []; + + assert.throws(() => resolver.resolve(roles, "any"), InvalidRoleError); + }); + + it("handles role with empty inherits array", () => { + const resolver = new PermissionInheritanceResolver(); + const roles: RoleNode[] = [ + { id: "standalone", permissions: ["read"], inherits: [] }, + ]; + + const result = resolver.resolve(roles, "standalone"); + + assert.deepEqual(result.effectivePermissions, ["read"]); + assert.deepEqual(result.inheritancePath, ["standalone"]); + }); + + it("handles complex inheritance with multiple levels and branches", () => { + const resolver = new PermissionInheritanceResolver(); + const roles: RoleNode[] = [ + { id: "base", permissions: ["base_perm"], inherits: [] }, + { id: "branch1", permissions: ["branch1_perm"], inherits: ["base"] }, + { id: "branch2", permissions: ["branch2_perm"], inherits: ["base"] }, + { id: "leaf1", permissions: ["leaf1_perm"], inherits: ["branch1"] }, + { id: "leaf2", permissions: ["leaf2_perm"], inherits: ["branch2"] }, + { id: "combined", permissions: ["combined_perm"], inherits: ["leaf1", "leaf2"] }, + ]; + + const result = resolver.resolve(roles, "combined"); + + assert.deepEqual(result.effectivePermissions, [ + "base_perm", + "branch1_perm", + "branch2_perm", + "combined_perm", + "leaf1_perm", + "leaf2_perm", + ]); + // Check that the path contains all expected roles (order may vary due to deduplication) + assert.ok(result.inheritancePath.includes("combined")); + assert.ok(result.inheritancePath.includes("leaf1")); + assert.ok(result.inheritancePath.includes("leaf2")); + assert.ok(result.inheritancePath.includes("branch1")); + assert.ok(result.inheritancePath.includes("branch2")); + assert.ok(result.inheritancePath.includes("base")); + }); + }); +}); diff --git a/packages/permission-inheritance/src/index.ts b/packages/permission-inheritance/src/index.ts new file mode 100644 index 0000000..5ee4dde --- /dev/null +++ b/packages/permission-inheritance/src/index.ts @@ -0,0 +1,334 @@ +export interface RoleNode { + id: string; + permissions: string[]; + inherits: string[]; +} + +export interface ResolveOptions { + maxDepth?: number; + maxRoles?: number; +} + +export interface ResolveResult { + roleId: string; + effectivePermissions: string[]; + inheritancePath: string[]; +} + +export class CycleDetectedError extends Error { + constructor( + public cycle: string[], + message = "Cycle detected in role inheritance graph" + ) { + super(message); + this.name = "CycleDetectedError"; + } +} + +export class MissingRoleError extends Error { + constructor( + public roleId: string, + public missingParentId: string, + message = "Missing inherited role" + ) { + super(message); + this.name = "MissingRoleError"; + } +} + +export class DepthLimitExceededError extends Error { + constructor( + public roleId: string, + public maxDepth: number, + message = "Maximum inheritance depth exceeded" + ) { + super(message); + this.name = "DepthLimitExceededError"; + } +} + +export class GraphSizeLimitExceededError extends Error { + constructor( + public maxRoles: number, + message = "Maximum number of roles exceeded" + ) { + super(message); + this.name = "GraphSizeLimitExceededError"; + } +} + +export class InvalidRoleError extends Error { + constructor( + public roleId: string, + message = "Invalid role definition" + ) { + super(message); + this.name = "InvalidRoleError"; + } +} + +export interface PermissionInheritanceResolverOptions { + defaultMaxDepth?: number; + defaultMaxRoles?: number; +} + +export class PermissionInheritanceResolver { + private readonly defaultMaxDepth: number; + private readonly defaultMaxRoles: number; + + constructor(options: PermissionInheritanceResolverOptions = {}) { + this.defaultMaxDepth = options.defaultMaxDepth ?? 100; + this.defaultMaxRoles = options.defaultMaxRoles ?? 1000; + } + + /** + * Validate role definitions + */ + private validateRoles(roles: Map): void { + if (roles.size > this.defaultMaxRoles) { + throw new GraphSizeLimitExceededError(this.defaultMaxRoles); + } + + for (const [id, role] of roles.entries()) { + if (!id || typeof id !== "string" || id.trim().length === 0) { + throw new InvalidRoleError(id, "Role ID must be a non-empty string"); + } + if (!Array.isArray(role.permissions)) { + throw new InvalidRoleError(id, "Role permissions must be an array"); + } + if (!Array.isArray(role.inherits)) { + throw new InvalidRoleError(id, "Role inherits must be an array"); + } + for (const perm of role.permissions) { + if (typeof perm !== "string") { + throw new InvalidRoleError(id, "All permissions must be strings"); + } + } + for (const parent of role.inherits) { + if (typeof parent !== "string") { + throw new InvalidRoleError(id, "All inherited role IDs must be strings"); + } + } + } + } + + /** + * Detect cycles in the inheritance graph using DFS + */ + private detectCycle( + roles: Map, + startRoleId: string, + visited: Set = new Set(), + path: string[] = [] + ): string[] | null { + if (path.includes(startRoleId)) { + const cycleStart = path.indexOf(startRoleId); + return [...path.slice(cycleStart), startRoleId]; + } + + if (visited.has(startRoleId)) { + return null; + } + + visited.add(startRoleId); + const newPath = [...path, startRoleId]; + + const role = roles.get(startRoleId); + if (!role) { + return null; + } + + for (const parentId of role.inherits) { + const cycle = this.detectCycle(roles, parentId, visited, newPath); + if (cycle) { + return cycle; + } + } + + return null; + } + + /** + * Check if the entire graph has any cycles + */ + private checkGraphCycles(roles: Map): void { + for (const roleId of roles.keys()) { + const cycle = this.detectCycle(roles, roleId); + if (cycle) { + throw new CycleDetectedError(cycle); + } + } + } + + /** + * Resolve effective permissions for a role + */ + resolve( + roles: RoleNode[] | Map, + roleId: string, + options: ResolveOptions = {} + ): ResolveResult { + const { maxDepth = this.defaultMaxDepth, maxRoles = this.defaultMaxRoles } = options; + + // Convert to map if needed + const roleMap = + roles instanceof Map + ? roles + : new Map(roles.map((role) => [role.id, role])); + + // Validate roles + if (roleMap.size > maxRoles) { + throw new GraphSizeLimitExceededError(maxRoles); + } + this.validateRoles(roleMap); + + // Check for cycles in the entire graph + this.checkGraphCycles(roleMap); + + // Check if target role exists + if (!roleMap.has(roleId)) { + throw new InvalidRoleError(roleId, "Role not found"); + } + + // Resolve permissions with cycle tracking + const effectivePermissions = new Set(); + const visited = new Set(); + const recursionStack = new Set(); + + this.resolveRecursive( + roleMap, + roleId, + effectivePermissions, + visited, + recursionStack, + 0, + maxDepth + ); + + // Build inheritance path separately (DFS order) + const inheritancePath = this.buildInheritancePath(roleMap, roleId, new Set()); + + // Return deterministic ordering (sorted alphabetically) + return { + roleId, + effectivePermissions: Array.from(effectivePermissions).sort(), + inheritancePath, + }; + } + + /** + * Build inheritance path for a role (DFS order) + */ + private buildInheritancePath( + roles: Map, + roleId: string, + visited: Set + ): string[] { + if (visited.has(roleId)) { + return []; + } + + const role = roles.get(roleId); + if (!role) { + return []; + } + + visited.add(roleId); + const path: string[] = [roleId]; + + for (const parentId of role.inherits) { + const parentPath = this.buildInheritancePath(roles, parentId, visited); + path.push(...parentPath); + } + + return path; + } + + /** + * Recursive resolution with depth and cycle tracking + */ + private resolveRecursive( + roles: Map, + roleId: string, + permissions: Set, + visited: Set, + recursionStack: Set, + currentDepth: number, + maxDepth: number + ): void { + // Check depth limit + if (currentDepth > maxDepth) { + throw new DepthLimitExceededError(roleId, maxDepth); + } + + // Check for cycles during resolution (node in current recursion path) + if (recursionStack.has(roleId)) { + const cycle = Array.from(recursionStack).concat([roleId]); + throw new CycleDetectedError(cycle); + } + + // Skip if already visited in a different branch (handles diamond inheritance) + if (visited.has(roleId)) { + return; + } + + const role = roles.get(roleId); + if (!role) { + // This should have been caught by validation, but check anyway + throw new MissingRoleError(roleId, roleId); + } + + // Add to recursion stack and mark as visited + recursionStack.add(roleId); + visited.add(roleId); + + // Add direct permissions + for (const permission of role.permissions) { + permissions.add(permission); + } + + // Resolve inherited permissions + for (const parentId of role.inherits) { + const parentRole = roles.get(parentId); + if (!parentRole) { + throw new MissingRoleError(roleId, parentId); + } + + this.resolveRecursive( + roles, + parentId, + permissions, + visited, + recursionStack, + currentDepth + 1, + maxDepth + ); + } + + // Remove from recursion stack (backtrack) + recursionStack.delete(roleId); + } + + /** + * Resolve effective permissions for multiple roles + */ + resolveMultiple( + roles: RoleNode[] | Map, + roleIds: string[], + options: ResolveOptions = {} + ): Map { + const results = new Map(); + + for (const roleId of roleIds) { + try { + const result = this.resolve(roles, roleId, options); + results.set(roleId, result); + } catch (error) { + // Propagate errors - caller can handle partial failures + throw error; + } + } + + return results; + } +} diff --git a/packages/permission-inheritance/tsconfig.json b/packages/permission-inheritance/tsconfig.json new file mode 100644 index 0000000..d8d9c3d --- /dev/null +++ b/packages/permission-inheritance/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "baseUrl": "." + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/version-constraints/package.json b/packages/version-constraints/package.json new file mode 100644 index 0000000..b72d32b --- /dev/null +++ b/packages/version-constraints/package.json @@ -0,0 +1,25 @@ +{ + "name": "@guildpass/version-constraints", + "version": "2.0.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": "node --test dist/**/*.test.js" + }, + "dependencies": { + "semver": "^7.8.5" + }, + "devDependencies": { + "@types/semver": "^7.8.0" + } +} diff --git a/packages/version-constraints/src/index.test.ts b/packages/version-constraints/src/index.test.ts new file mode 100644 index 0000000..3eb0d21 --- /dev/null +++ b/packages/version-constraints/src/index.test.ts @@ -0,0 +1,482 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + parseVersion, + parseConstraint, + compareVersions, + satisfiesConstraint, + evaluate, + isGreaterThan, + isGreaterThanOrEqual, + isLessThan, + isLessThanOrEqual, + isEqual, + InvalidVersionError, + InvalidConstraintError, + type SemanticVersion, + type VersionConstraint, +} from "./index.js"; + +describe("Semantic Version Parser and Constraint Evaluator", () => { + describe("parseVersion", () => { + it("parses valid semantic versions correctly", () => { + const v1 = parseVersion("1.0.0"); + assert.equal(v1.major, 1); + assert.equal(v1.minor, 0); + assert.equal(v1.patch, 0); + assert.equal(v1.raw, "1.0.0"); + + const v2 = parseVersion("2.3.1"); + assert.equal(v2.major, 2); + assert.equal(v2.minor, 3); + assert.equal(v2.patch, 1); + assert.equal(v2.raw, "2.3.1"); + + const v3 = parseVersion("3.0.0-beta.1"); + assert.equal(v3.major, 3); + assert.equal(v3.minor, 0); + assert.equal(v3.patch, 0); + assert.deepEqual(v3.prerelease, ["beta", 1]); + assert.equal(v3.raw, "3.0.0-beta.1"); + }); + + it("parses versions with prerelease identifiers", () => { + const v1 = parseVersion("1.0.0-alpha"); + assert.deepEqual(v1.prerelease, ["alpha"]); + + const v2 = parseVersion("1.0.0-alpha.1"); + assert.deepEqual(v2.prerelease, ["alpha", 1]); + + const v3 = parseVersion("1.0.0-0.3.7"); + assert.deepEqual(v3.prerelease, [0, 3, 7]); + + const v4 = parseVersion("1.0.0-x.7.z.92"); + assert.deepEqual(v4.prerelease, ["x", 7, "z", 92]); + }); + + it("parses versions with build metadata", () => { + const v1 = parseVersion("1.0.0+20130313144700"); + assert.deepEqual(v1.build, ["20130313144700"]); + + const v2 = parseVersion("1.0.0-alpha+001"); + assert.deepEqual(v2.prerelease, ["alpha"]); + assert.deepEqual(v2.build, ["001"]); + }); + + it("rejects malformed versions", () => { + assert.throws( + () => parseVersion(""), + (err: any) => err instanceof InvalidVersionError + ); + + assert.throws( + () => parseVersion("invalid"), + (err: any) => err instanceof InvalidVersionError + ); + + assert.throws( + () => parseVersion("1.2.3.4"), + (err: any) => err instanceof InvalidVersionError + ); + }); + + it("rejects non-string input", () => { + assert.throws( + () => parseVersion(null as any), + (err: any) => err instanceof InvalidVersionError + ); + }); + }); + + describe("parseConstraint", () => { + it("parses valid constraint strings", () => { + const c1 = parseConstraint("=1.2.3"); + assert.equal(c1.operator, "="); + assert.equal(c1.version.raw, "1.2.3"); + assert.equal(c1.raw, "=1.2.3"); + + const c2 = parseConstraint(">1.2.3"); + assert.equal(c2.operator, ">"); + assert.equal(c2.version.raw, "1.2.3"); + + const c3 = parseConstraint(">=1.2.3"); + assert.equal(c3.operator, ">="); + assert.equal(c3.version.raw, "1.2.3"); + + const c4 = parseConstraint("<2.0.0"); + assert.equal(c4.operator, "<"); + assert.equal(c4.version.raw, "2.0.0"); + + const c5 = parseConstraint("<=2.0.0"); + assert.equal(c5.operator, "<="); + assert.equal(c5.version.raw, "2.0.0"); + }); + + it("parses constraints with prerelease versions", () => { + const c1 = parseConstraint(">=1.0.0-alpha"); + assert.equal(c1.operator, ">="); + assert.equal(c1.version.raw, "1.0.0-alpha"); + }); + + it("handles whitespace in constraint strings", () => { + const c1 = parseConstraint(" > 1.2.3 "); + assert.equal(c1.operator, ">"); + assert.equal(c1.version.raw, "1.2.3"); + assert.equal(c1.raw, "> 1.2.3"); + }); + + it("rejects malformed constraints", () => { + assert.throws( + () => parseConstraint(""), + (err: any) => err instanceof InvalidConstraintError + ); + + assert.throws( + () => parseConstraint("1.2.3"), + (err: any) => err instanceof InvalidConstraintError + ); + + assert.throws( + () => parseConstraint("==1.2.3"), + (err: any) => err instanceof InvalidConstraintError + ); + + assert.throws( + () => parseConstraint("!1.2.3"), + (err: any) => err instanceof InvalidConstraintError + ); + }); + + it("rejects invalid operators", () => { + assert.throws( + () => parseConstraint(">>1.2.3"), + (err: any) => err instanceof InvalidConstraintError + ); + + assert.throws( + () => parseConstraint("<>1.2.3"), + (err: any) => err instanceof InvalidConstraintError + ); + }); + }); + + describe("compareVersions", () => { + it("correctly compares versions with numeric components", () => { + const v1 = parseVersion("1.10.0"); + const v2 = parseVersion("1.9.0"); + assert.equal(compareVersions(v1, v2), 1); // 1.10.0 > 1.9.0 + assert.equal(compareVersions(v2, v1), -1); // 1.9.0 < 1.10.0 + }); + + it("handles major version precedence", () => { + const v1 = parseVersion("2.0.0"); + const v2 = parseVersion("1.9.9"); + assert.equal(compareVersions(v1, v2), 1); + assert.equal(compareVersions(v2, v1), -1); + }); + + it("handles minor version precedence", () => { + const v1 = parseVersion("1.5.0"); + const v2 = parseVersion("1.4.9"); + assert.equal(compareVersions(v1, v2), 1); + assert.equal(compareVersions(v2, v1), -1); + }); + + it("handles patch version precedence", () => { + const v1 = parseVersion("1.0.5"); + const v2 = parseVersion("1.0.4"); + assert.equal(compareVersions(v1, v2), 1); + assert.equal(compareVersions(v2, v1), -1); + }); + + it("handles equal versions", () => { + const v1 = parseVersion("1.2.3"); + const v2 = parseVersion("1.2.3"); + assert.equal(compareVersions(v1, v2), 0); + }); + + it("handles prerelease precedence according to semver spec", () => { + const v1 = parseVersion("1.0.0-alpha"); + const v2 = parseVersion("1.0.0-alpha.1"); + const v3 = parseVersion("1.0.0-alpha.beta"); + const v4 = parseVersion("1.0.0-beta"); + const v5 = parseVersion("1.0.0-beta.2"); + const v6 = parseVersion("1.0.0-beta.11"); + const v7 = parseVersion("1.0.0-rc.1"); + const v8 = parseVersion("1.0.0"); + + // Prerelease versions are less than the normal version + assert.equal(compareVersions(v1, v8), -1); + assert.equal(compareVersions(v8, v1), 1); + + // alpha < alpha.1 < alpha.beta < beta < beta.2 < beta.11 < rc.1 + assert.equal(compareVersions(v1, v2), -1); + assert.equal(compareVersions(v2, v3), -1); + assert.equal(compareVersions(v3, v4), -1); + assert.equal(compareVersions(v4, v5), -1); + assert.equal(compareVersions(v5, v6), -1); + assert.equal(compareVersions(v6, v7), -1); + }); + + it("handles numeric identifiers in prerelease", () => { + const v1 = parseVersion("1.0.0-1"); + const v2 = parseVersion("1.0.0-2"); + assert.equal(compareVersions(v1, v2), -1); + }); + + it("build metadata does not affect precedence", () => { + const v1 = parseVersion("1.0.0+20130313144700"); + const v2 = parseVersion("1.0.0+20130313144701"); + assert.equal(compareVersions(v1, v2), 0); + }); + }); + + describe("Comparison functions", () => { + it("isGreaterThan works correctly", () => { + const v1 = parseVersion("1.10.0"); + const v2 = parseVersion("1.9.0"); + assert.equal(isGreaterThan(v1, v2), true); + assert.equal(isGreaterThan(v2, v1), false); + assert.equal(isGreaterThan(v1, v1), false); + }); + + it("isGreaterThanOrEqual works correctly", () => { + const v1 = parseVersion("1.10.0"); + const v2 = parseVersion("1.9.0"); + const v3 = parseVersion("1.10.0"); + assert.equal(isGreaterThanOrEqual(v1, v2), true); + assert.equal(isGreaterThanOrEqual(v2, v1), false); + assert.equal(isGreaterThanOrEqual(v1, v3), true); + }); + + it("isLessThan works correctly", () => { + const v1 = parseVersion("1.9.0"); + const v2 = parseVersion("1.10.0"); + assert.equal(isLessThan(v1, v2), true); + assert.equal(isLessThan(v2, v1), false); + assert.equal(isLessThan(v1, v1), false); + }); + + it("isLessThanOrEqual works correctly", () => { + const v1 = parseVersion("1.9.0"); + const v2 = parseVersion("1.10.0"); + const v3 = parseVersion("1.9.0"); + assert.equal(isLessThanOrEqual(v1, v2), true); + assert.equal(isLessThanOrEqual(v2, v1), false); + assert.equal(isLessThanOrEqual(v1, v3), true); + }); + + it("isEqual works correctly", () => { + const v1 = parseVersion("1.2.3"); + const v2 = parseVersion("1.2.3"); + const v3 = parseVersion("1.2.4"); + assert.equal(isEqual(v1, v2), true); + assert.equal(isEqual(v1, v3), false); + }); + }); + + describe("satisfiesConstraint", () => { + it("evaluates equality constraints", () => { + const version = parseVersion("1.2.3"); + const constraint = parseConstraint("=1.2.3"); + assert.equal(satisfiesConstraint(version, constraint), true); + + const version2 = parseVersion("1.2.4"); + assert.equal(satisfiesConstraint(version2, constraint), false); + }); + + it("evaluates greater-than constraints", () => { + const version = parseVersion("1.2.4"); + const constraint = parseConstraint(">1.2.3"); + assert.equal(satisfiesConstraint(version, constraint), true); + + const version2 = parseVersion("1.2.3"); + assert.equal(satisfiesConstraint(version2, constraint), false); + + const version3 = parseVersion("1.2.2"); + assert.equal(satisfiesConstraint(version3, constraint), false); + }); + + it("evaluates greater-than-or-equal constraints", () => { + const version = parseVersion("1.2.3"); + const constraint = parseConstraint(">=1.2.3"); + assert.equal(satisfiesConstraint(version, constraint), true); + + const version2 = parseVersion("1.2.4"); + assert.equal(satisfiesConstraint(version2, constraint), true); + + const version3 = parseVersion("1.2.2"); + assert.equal(satisfiesConstraint(version3, constraint), false); + }); + + it("evaluates less-than constraints", () => { + const version = parseVersion("1.2.2"); + const constraint = parseConstraint("<1.2.3"); + assert.equal(satisfiesConstraint(version, constraint), true); + + const version2 = parseVersion("1.2.3"); + assert.equal(satisfiesConstraint(version2, constraint), false); + + const version3 = parseVersion("1.2.4"); + assert.equal(satisfiesConstraint(version3, constraint), false); + }); + + it("evaluates less-than-or-equal constraints", () => { + const version = parseVersion("1.2.3"); + const constraint = parseConstraint("<=1.2.3"); + assert.equal(satisfiesConstraint(version, constraint), true); + + const version2 = parseVersion("1.2.2"); + assert.equal(satisfiesConstraint(version2, constraint), true); + + const version3 = parseVersion("1.2.4"); + assert.equal(satisfiesConstraint(version3, constraint), false); + }); + + it("handles prerelease versions in constraints", () => { + const version = parseVersion("1.0.0-alpha.1"); + const constraint = parseConstraint(">=1.0.0-alpha"); + assert.equal(satisfiesConstraint(version, constraint), true); + + const version2 = parseVersion("1.0.0"); + assert.equal(satisfiesConstraint(version2, constraint), true); + }); + + it("handles numeric version comparisons correctly", () => { + const version = parseVersion("1.10.0"); + const constraint = parseConstraint(">1.9.0"); + assert.equal(satisfiesConstraint(version, constraint), true); + }); + }); + + describe("evaluate", () => { + it("combines parsing and evaluation in a single call", () => { + assert.equal(evaluate("1.2.3", "=1.2.3"), true); + assert.equal(evaluate("1.2.4", ">1.2.3"), true); + assert.equal(evaluate("1.2.3", ">=1.2.3"), true); + assert.equal(evaluate("1.2.2", "<1.2.3"), true); + assert.equal(evaluate("1.2.3", "<=1.2.3"), true); + }); + + it("throws InvalidVersionError for invalid version strings", () => { + assert.throws( + () => evaluate("invalid", ">=1.0.0"), + (err: any) => err instanceof InvalidVersionError + ); + }); + + it("throws InvalidConstraintError for invalid constraint strings", () => { + assert.throws( + () => evaluate("1.0.0", "invalid"), + (err: any) => err instanceof InvalidConstraintError + ); + }); + }); + + describe("Deterministic evaluation", () => { + it("produces consistent results across multiple evaluations", () => { + const version = parseVersion("1.2.3"); + const constraint = parseConstraint(">=1.0.0"); + + const results = []; + for (let i = 0; i < 10; i++) { + results.push(satisfiesConstraint(version, constraint)); + } + + assert.equal(results.every((r) => r === true), true); + }); + + it("compareVersions is deterministic", () => { + const v1 = parseVersion("1.10.0"); + const v2 = parseVersion("1.9.0"); + + const results = []; + for (let i = 0; i < 10; i++) { + results.push(compareVersions(v1, v2)); + } + + assert.equal(results.every((r) => r === 1), true); + }); + }); + + describe("Strong typing", () => { + it("SemanticVersion interface has correct types", () => { + const version: SemanticVersion = parseVersion("1.2.3"); + assert.equal(typeof version.major, "number"); + assert.equal(typeof version.minor, "number"); + assert.equal(typeof version.patch, "number"); + assert.equal(typeof version.raw, "string"); + assert.equal(Array.isArray(version.prerelease), true); + assert.equal(Array.isArray(version.build), true); + }); + + it("VersionConstraint interface has correct types", () => { + const constraint: VersionConstraint = parseConstraint(">=1.2.3"); + assert.equal(typeof constraint.operator, "string"); + assert.equal(typeof constraint.raw, "string"); + assert.equal(typeof constraint.version, "object"); + }); + }); + + describe("Boundary cases", () => { + it("handles version 0.0.0", () => { + const version = parseVersion("0.0.0"); + assert.equal(version.major, 0); + assert.equal(version.minor, 0); + assert.equal(version.patch, 0); + }); + + it("handles large version numbers", () => { + const version = parseVersion("999.999.999"); + assert.equal(version.major, 999); + assert.equal(version.minor, 999); + assert.equal(version.patch, 999); + }); + + it("handles version with numeric prerelease identifiers", () => { + const version = parseVersion("1.0.0-alpha.1"); + assert.deepEqual(version.prerelease, ["alpha", 1]); + }); + + it("handles constraint with exact version match", () => { + const version = parseVersion("1.2.3"); + const constraint = parseConstraint("=1.2.3"); + assert.equal(satisfiesConstraint(version, constraint), true); + }); + + it("handles boundary comparisons", () => { + const v1 = parseVersion("1.0.0"); + const v2 = parseVersion("1.0.1"); + const v3 = parseVersion("1.1.0"); + const v4 = parseVersion("2.0.0"); + + assert.equal(isLessThan(v1, v2), true); + assert.equal(isLessThan(v2, v3), true); + assert.equal(isLessThan(v3, v4), true); + }); + }); + + describe("Prerelease edge cases", () => { + it("handles empty prerelease array for stable versions", () => { + const version = parseVersion("1.0.0"); + assert.deepEqual(version.prerelease, []); + }); + + it("handles prerelease with mixed types", () => { + const version = parseVersion("1.0.0-alpha.1.beta.2"); + assert.deepEqual(version.prerelease, ["alpha", 1, "beta", 2]); + }); + + it("correctly compares prerelease with different identifiers", () => { + const v1 = parseVersion("1.0.0-alpha"); + const v2 = parseVersion("1.0.0-beta"); + assert.equal(isLessThan(v1, v2), true); + }); + + it("handles prerelease constraint with stable version", () => { + const version = parseVersion("1.0.0"); + const constraint = parseConstraint(">=1.0.0-alpha"); + assert.equal(satisfiesConstraint(version, constraint), true); + }); + }); +}); diff --git a/packages/version-constraints/src/index.ts b/packages/version-constraints/src/index.ts new file mode 100644 index 0000000..4b5cb5c --- /dev/null +++ b/packages/version-constraints/src/index.ts @@ -0,0 +1,213 @@ +import { parse, compare, satisfies } from "semver"; + +/** + * Custom Error thrown when an invalid semantic version string is provided. + */ +export class InvalidVersionError extends Error { + constructor(message: string) { + super(message); + this.name = "InvalidVersionError"; + } +} + +/** + * Custom Error thrown when an invalid constraint string is provided. + */ +export class InvalidConstraintError extends Error { + constructor(message: string) { + super(message); + this.name = "InvalidConstraintError"; + } +} + +/** + * Represents a parsed semantic version with strongly typed components. + */ +export interface SemanticVersion { + major: number; + minor: number; + patch: number; + prerelease: readonly (string | number)[]; + build: readonly string[]; + raw: string; +} + +/** + * Represents a version constraint operator. + */ +export type ConstraintOperator = "=" | ">" | ">=" | "<" | "<="; + +/** + * Represents a parsed version constraint. + */ +export interface VersionConstraint { + operator: ConstraintOperator; + version: SemanticVersion; + raw: string; +} + +/** + * Parses a semantic version string into a strongly typed SemanticVersion object. + * + * @param versionString - The semantic version string to parse (e.g., "1.2.3", "2.0.0-beta.1") + * @returns A SemanticVersion object + * @throws InvalidVersionError if the version string is malformed + */ +export function parseVersion(versionString: string): SemanticVersion { + if (typeof versionString !== "string" || versionString.trim() === "") { + throw new InvalidVersionError(`Invalid version string: "${versionString}"`); + } + + const parsed = parse(versionString); + if (!parsed) { + throw new InvalidVersionError(`Invalid semantic version: "${versionString}"`); + } + + return { + major: parsed.major, + minor: parsed.minor, + patch: parsed.patch, + prerelease: parsed.prerelease, + build: parsed.build, + raw: parsed.raw, + }; +} + +/** + * Parses a version constraint string into a VersionConstraint object. + * Supports operators: =, >, >=, <, <= + * + * @param constraintString - The constraint string to parse (e.g., ">=1.2.3", "<2.0.0") + * @returns A VersionConstraint object + * @throws InvalidConstraintError if the constraint string is malformed + */ +export function parseConstraint(constraintString: string): VersionConstraint { + if (typeof constraintString !== "string" || constraintString.trim() === "") { + throw new InvalidConstraintError(`Invalid constraint string: "${constraintString}"`); + } + + const trimmed = constraintString.trim(); + + // Match operator and version + const match = trimmed.match(/^([><=]+)(.+)$/); + if (!match) { + throw new InvalidConstraintError(`Invalid constraint format: "${constraintString}"`); + } + + const operator = match[1] as ConstraintOperator; + const versionString = match[2].trim(); + + // Validate operator + const validOperators: ConstraintOperator[] = ["=", ">", ">=", "<", "<="]; + if (!validOperators.includes(operator)) { + throw new InvalidConstraintError(`Invalid constraint operator: "${operator}"`); + } + + const version = parseVersion(versionString); + + return { + operator, + version, + raw: trimmed, + }; +} + +/** + * Compares two semantic versions according to semantic version precedence rules. + * + * @param versionA - First version to compare + * @param versionB - Second version to compare + * @returns -1 if versionA < versionB, 0 if versionA === versionB, 1 if versionA > versionB + */ +export function compareVersions(versionA: SemanticVersion, versionB: SemanticVersion): number { + return compare(versionA.raw, versionB.raw); +} + +/** + * Checks if a version satisfies a given constraint. + * + * @param version - The version to check + * @param constraint - The constraint to evaluate + * @returns true if the version satisfies the constraint, false otherwise + */ +export function satisfiesConstraint(version: SemanticVersion, constraint: VersionConstraint): boolean { + const versionString = version.raw; + const constraintString = constraint.raw; + + try { + return satisfies(versionString, constraintString); + } catch (error) { + throw new InvalidConstraintError(`Failed to evaluate constraint: "${constraintString}"`); + } +} + +/** + * Evaluates whether a version string satisfies a constraint string. + * This is a convenience function that combines parsing and evaluation. + * + * @param versionString - The version string to check + * @param constraintString - The constraint string to evaluate + * @returns true if the version satisfies the constraint, false otherwise + * @throws InvalidVersionError if the version string is malformed + * @throws InvalidConstraintError if the constraint string is malformed + */ +export function evaluate(versionString: string, constraintString: string): boolean { + const version = parseVersion(versionString); + const constraint = parseConstraint(constraintString); + return satisfiesConstraint(version, constraint); +} + +/** + * Determines if version A is greater than version B. + * + * @param versionA - First version + * @param versionB - Second version + * @returns true if versionA > versionB + */ +export function isGreaterThan(versionA: SemanticVersion, versionB: SemanticVersion): boolean { + return compareVersions(versionA, versionB) > 0; +} + +/** + * Determines if version A is greater than or equal to version B. + * + * @param versionA - First version + * @param versionB - Second version + * @returns true if versionA >= versionB + */ +export function isGreaterThanOrEqual(versionA: SemanticVersion, versionB: SemanticVersion): boolean { + return compareVersions(versionA, versionB) >= 0; +} + +/** + * Determines if version A is less than version B. + * + * @param versionA - First version + * @param versionB - Second version + * @returns true if versionA < versionB + */ +export function isLessThan(versionA: SemanticVersion, versionB: SemanticVersion): boolean { + return compareVersions(versionA, versionB) < 0; +} + +/** + * Determines if version A is less than or equal to version B. + * + * @param versionA - First version + * @param versionB - Second version + * @returns true if versionA <= versionB + */ +export function isLessThanOrEqual(versionA: SemanticVersion, versionB: SemanticVersion): boolean { + return compareVersions(versionA, versionB) <= 0; +} + +/** + * Determines if two versions are equal. + * + * @param versionA - First version + * @param versionB - Second version + * @returns true if versionA === versionB + */ +export function isEqual(versionA: SemanticVersion, versionB: SemanticVersion): boolean { + return compareVersions(versionA, versionB) === 0; +} diff --git a/packages/version-constraints/tsconfig.json b/packages/version-constraints/tsconfig.json new file mode 100644 index 0000000..c99ec7b --- /dev/null +++ b/packages/version-constraints/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9599b4a..fd8003b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,8 @@ importers: packages/eligibility-rules: {} + packages/event-deduplication: {} + packages/exact-amount: {} packages/idempotency: @@ -90,10 +92,14 @@ importers: specifier: ^1.2.1 version: 1.6.1(@types/node@20.19.43)(supports-color@8.1.1) + packages/nonce-manager: {} + packages/payload-migrations: {} packages/permission-expression: {} + packages/permission-inheritance: {} + packages/policy-explanation: {} packages/priority-queue: @@ -116,6 +122,8 @@ importers: specifier: ^1.2.1 version: 1.6.1(@types/node@20.19.43)(supports-color@8.1.1) + packages/resource-id: {} + packages/retry-policy: {} packages/shared-types: {} @@ -164,6 +172,16 @@ importers: specifier: ^5.4.0 version: 5.9.3 + packages/version-constraints: + dependencies: + semver: + specifier: ^7.8.5 + version: 7.8.5 + devDependencies: + '@types/semver': + specifier: ^7.8.0 + version: 7.8.0 + packages/voting-power-snapshot: {} packages/webhook-verification: @@ -961,6 +979,9 @@ packages: '@types/node@20.19.43': resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + '@types/semver@7.8.0': + resolution: {integrity: sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==} + '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} @@ -3050,6 +3071,8 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/semver@7.8.0': {} + '@types/stack-utils@2.0.3': {} '@types/yargs-parser@21.0.3': {}