From 397d60ed4e32b39d08e335a55a7debb8ca44d560 Mon Sep 17 00:00:00 2001 From: city in the sky Date: Wed, 8 Jul 2026 00:12:47 +0800 Subject: [PATCH] Add email ownership core engine --- .../core/ownership-engine.mjs | 388 ++++++++++++++++++ .../email-ownership-tracker/docs/CORE_API.md | 73 ++++ .../fixtures/ownership-engine-cases.json | 87 ++++ .../tests/ownership-engine.test.mjs | 184 +++++++++ 4 files changed, 732 insertions(+) create mode 100644 tools/v1/team/email-ownership-tracker/core/ownership-engine.mjs create mode 100644 tools/v1/team/email-ownership-tracker/docs/CORE_API.md create mode 100644 tools/v1/team/email-ownership-tracker/fixtures/ownership-engine-cases.json create mode 100644 tools/v1/team/email-ownership-tracker/tests/ownership-engine.test.mjs diff --git a/tools/v1/team/email-ownership-tracker/core/ownership-engine.mjs b/tools/v1/team/email-ownership-tracker/core/ownership-engine.mjs new file mode 100644 index 00000000..50a2715e --- /dev/null +++ b/tools/v1/team/email-ownership-tracker/core/ownership-engine.mjs @@ -0,0 +1,388 @@ +const ACTIONS = new Set(["claim", "release", "transfer"]); + +const LIMITS = { + MAX_EVENTS: 500, + MAX_MESSAGE_ID_LENGTH: 160, + MAX_EVENT_ID_LENGTH: 120, + MAX_EMAIL_LENGTH: 254, + MAX_REASON_LENGTH: 800, + MAX_SUBJECT_LENGTH: 240, +}; + +const SAFE_ID = /^[A-Za-z0-9._:@-]+$/; +const SAFE_EMAIL = /^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/; +const UTC_ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/; +const CONTROL_CHARS = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/g; +const TAGS = /<[^>]*>/g; + +export class OwnershipEngineError extends Error { + constructor(code, message, field = "input") { + super(message); + this.name = "OwnershipEngineError"; + this.code = code; + this.field = field; + } +} + +function assertPlainObject(value, field) { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new OwnershipEngineError("invalid-shape", `${field} must be an object`, field); + } +} + +function sanitizeText(value, limit, field) { + if (value === undefined || value === null) return ""; + if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") { + throw new OwnershipEngineError("invalid-text", `${field} must be a primitive value`, field); + } + + return String(value).replace(CONTROL_CHARS, "").replace(TAGS, "").trim().slice(0, limit); +} + +export function normalizeId(value, field = "id", limit = LIMITS.MAX_EVENT_ID_LENGTH) { + if (typeof value !== "string") { + throw new OwnershipEngineError("invalid-id", `${field} must be a string`, field); + } + + const id = value.trim(); + if ( + !id || + id.length > limit || + id.includes("..") || + /[\\/<>#?%&\s]/.test(id) || + !SAFE_ID.test(id) + ) { + throw new OwnershipEngineError("invalid-id", `${field} is not a safe identifier`, field); + } + + return id; +} + +export function normalizeEmail(value, field = "email") { + if (typeof value !== "string") { + throw new OwnershipEngineError("invalid-email", `${field} must be a string`, field); + } + + const email = value.trim().toLowerCase(); + if ( + !email || + email.length > LIMITS.MAX_EMAIL_LENGTH || + /[\r\n\0]/.test(email) || + !SAFE_EMAIL.test(email) + ) { + throw new OwnershipEngineError("invalid-email", `${field} is not a safe email address`, field); + } + + return email; +} + +export function normalizeTimestamp(value, field = "createdAt") { + if (typeof value !== "string") { + throw new OwnershipEngineError("invalid-timestamp", `${field} must be an ISO timestamp`, field); + } + + const time = value.trim(); + const parsed = Date.parse(time); + if (!UTC_ISO_TIMESTAMP.test(time) || !Number.isFinite(parsed)) { + throw new OwnershipEngineError( + "invalid-timestamp", + `${field} must be a UTC ISO timestamp`, + field, + ); + } + + return new Date(parsed).toISOString(); +} + +export function normalizeOwnershipEvent(value) { + assertPlainObject(value, "event"); + + const action = String(value.action ?? "") + .trim() + .toLowerCase(); + if (!ACTIONS.has(action)) { + throw new OwnershipEngineError( + "invalid-action", + "action must be claim, release, or transfer", + "action", + ); + } + + const normalized = { + eventId: normalizeId(value.eventId ?? value.id, "eventId", LIMITS.MAX_EVENT_ID_LENGTH), + messageId: normalizeId(value.messageId, "messageId", LIMITS.MAX_MESSAGE_ID_LENGTH), + action, + actorEmail: normalizeEmail(value.actorEmail, "actorEmail"), + createdAt: normalizeTimestamp(value.createdAt), + reason: sanitizeText(value.reason ?? "", LIMITS.MAX_REASON_LENGTH, "reason"), + subject: sanitizeText(value.subject ?? "", LIMITS.MAX_SUBJECT_LENGTH, "subject"), + ownerEmail: null, + nextOwnerEmail: null, + }; + + if (action === "claim") { + normalized.ownerEmail = normalizeEmail(value.ownerEmail ?? value.actorEmail, "ownerEmail"); + } + + if (action === "release") { + normalized.ownerEmail = + value.ownerEmail === undefined || value.ownerEmail === null || value.ownerEmail === "" + ? normalized.actorEmail + : normalizeEmail(value.ownerEmail, "ownerEmail"); + } + + if (action === "transfer") { + normalized.ownerEmail = + value.ownerEmail === undefined || value.ownerEmail === null || value.ownerEmail === "" + ? normalized.actorEmail + : normalizeEmail(value.ownerEmail, "ownerEmail"); + normalized.nextOwnerEmail = normalizeEmail(value.nextOwnerEmail, "nextOwnerEmail"); + } + + return normalized; +} + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function appendHistory(current, event, outcome) { + return [ + ...current, + { + eventId: event.eventId, + action: event.action, + actorEmail: event.actorEmail, + ownerEmail: event.ownerEmail, + nextOwnerEmail: event.nextOwnerEmail, + createdAt: event.createdAt, + outcome, + reason: event.reason, + }, + ]; +} + +function recordConflict(conflicts, event, code, message, currentOwner = null) { + conflicts.push({ + eventId: event.eventId, + messageId: event.messageId, + code, + message, + actorEmail: event.actorEmail, + requestedOwnerEmail: event.ownerEmail, + currentOwnerEmail: currentOwner?.ownerEmail ?? null, + createdAt: event.createdAt, + }); +} + +export function buildOwnershipLedger(events) { + if (!Array.isArray(events)) { + throw new OwnershipEngineError("invalid-events", "events must be an array", "events"); + } + + if (events.length > LIMITS.MAX_EVENTS) { + throw new OwnershipEngineError( + "too-many-events", + `events exceed safe limit of ${LIMITS.MAX_EVENTS}`, + "events", + ); + } + + const owners = new Map(); + const conflicts = []; + const normalizedEvents = events.map(normalizeOwnershipEvent); + + for (const event of normalizedEvents) { + const current = owners.get(event.messageId); + + if (event.action === "claim") { + if (current && current.ownerEmail !== event.ownerEmail) { + recordConflict( + conflicts, + event, + "already-owned", + `message ${event.messageId} is already owned`, + current, + ); + owners.set(event.messageId, { + ...current, + history: appendHistory(current.history, event, "conflict"), + }); + continue; + } + + owners.set(event.messageId, { + messageId: event.messageId, + subject: event.subject || current?.subject || "", + ownerEmail: event.ownerEmail, + claimedAt: current?.claimedAt ?? event.createdAt, + updatedAt: event.createdAt, + history: appendHistory(current?.history ?? [], event, "applied"), + }); + continue; + } + + if (event.action === "release") { + if (!current) { + recordConflict(conflicts, event, "not-owned", `message ${event.messageId} has no owner`); + continue; + } + + if (current.ownerEmail !== event.ownerEmail) { + recordConflict( + conflicts, + event, + "not-current-owner", + `release requested by non-owner for ${event.messageId}`, + current, + ); + owners.set(event.messageId, { + ...current, + history: appendHistory(current.history, event, "conflict"), + }); + continue; + } + + owners.delete(event.messageId); + continue; + } + + if (event.action === "transfer") { + if (!current) { + recordConflict(conflicts, event, "not-owned", `message ${event.messageId} has no owner`); + continue; + } + + if (current.ownerEmail !== event.ownerEmail) { + recordConflict( + conflicts, + event, + "not-current-owner", + `transfer requested by non-owner for ${event.messageId}`, + current, + ); + owners.set(event.messageId, { + ...current, + history: appendHistory(current.history, event, "conflict"), + }); + continue; + } + + owners.set(event.messageId, { + ...current, + ownerEmail: event.nextOwnerEmail, + updatedAt: event.createdAt, + history: appendHistory(current.history, event, "applied"), + }); + } + } + + const activeOwners = [...owners.values()].sort((left, right) => + left.messageId.localeCompare(right.messageId), + ); + + return { + status: "ready", + activeOwners, + conflicts, + summary: { + totalEvents: normalizedEvents.length, + activeOwners: activeOwners.length, + conflicts: conflicts.length, + }, + }; +} + +export function createOwnershipState(result, options = {}) { + if (options.loading) { + return { status: "loading", message: "Loading ownership history" }; + } + + if (options.error) { + const error = options.error; + return { + status: "error", + code: error.code ?? "ownership-error", + message: error.message ?? "Ownership tracker failed", + }; + } + + if (!result || result.activeOwners.length === 0) { + return { status: "empty", activeOwners: [], conflicts: result?.conflicts ?? [] }; + } + + return { + status: "ready", + activeOwners: clone(result.activeOwners), + conflicts: clone(result.conflicts), + summary: { ...result.summary }, + }; +} + +export function createOwnershipEngine(initialEvents = [], options = {}) { + const now = options.now ?? (() => new Date().toISOString()); + const prefix = options.idPrefix ?? "ownership-event"; + let counter = options.startAt ?? 0; + let events = initialEvents.map(normalizeOwnershipEvent); + + function nextEventId() { + counter += 1; + return `${prefix}-${String(counter).padStart(3, "0")}`; + } + + function append(event) { + const normalized = normalizeOwnershipEvent(event); + events = [...events, normalized]; + return clone(normalized); + } + + return { + listEvents() { + return clone(events); + }, + + getLedger() { + return createOwnershipState(buildOwnershipLedger(events)); + }, + + claim(messageId, actorEmail, details = {}) { + return append({ + eventId: details.eventId ?? nextEventId(), + messageId, + action: "claim", + actorEmail, + ownerEmail: details.ownerEmail ?? actorEmail, + subject: details.subject ?? "", + reason: details.reason ?? "", + createdAt: details.createdAt ?? now(), + }); + }, + + release(messageId, actorEmail, details = {}) { + return append({ + eventId: details.eventId ?? nextEventId(), + messageId, + action: "release", + actorEmail, + ownerEmail: details.ownerEmail ?? actorEmail, + reason: details.reason ?? "", + createdAt: details.createdAt ?? now(), + }); + }, + + transfer(messageId, actorEmail, nextOwnerEmail, details = {}) { + return append({ + eventId: details.eventId ?? nextEventId(), + messageId, + action: "transfer", + actorEmail, + ownerEmail: details.ownerEmail ?? actorEmail, + nextOwnerEmail, + reason: details.reason ?? "", + createdAt: details.createdAt ?? now(), + }); + }, + }; +} + +export { ACTIONS, LIMITS }; diff --git a/tools/v1/team/email-ownership-tracker/docs/CORE_API.md b/tools/v1/team/email-ownership-tracker/docs/CORE_API.md new file mode 100644 index 00000000..f8ba29ed --- /dev/null +++ b/tools/v1/team/email-ownership-tracker/docs/CORE_API.md @@ -0,0 +1,73 @@ +# Email Ownership Tracker Core API + +## Purpose + +The core engine models folder-local ownership history for shared team mailbox +threads. It is intentionally isolated: no inbox reads, mailbox writes, +notifications, persistence, routes, auth, wallet, Stellar, database, provider +calls, production data, secrets, or app-shell integration. + +## Public Helpers + +`core/ownership-engine.mjs` exports: + +- `normalizeOwnershipEvent(event)` to validate and normalize one ownership event. +- `buildOwnershipLedger(events)` to derive active owners and conflicts from a + bounded event history. +- `createOwnershipState(result, options)` to map ledger data into loading, + empty, ready, or error states for a future UI. +- `createOwnershipEngine(initialEvents, options)` to create an in-memory, + deterministic service surface for claim, release, transfer, list, and ledger + operations. + +## Inputs + +Ownership events are plain objects: + +```text +eventId: stable folder-local id +messageId: safe thread or message id +action: claim, release, or transfer +actorEmail: user making the change +ownerEmail: current or requested owner +nextOwnerEmail: transfer target +createdAt: ISO timestamp +subject: optional display subject +reason: optional handoff note +``` + +The engine rejects unsafe ids, malformed emails, unsupported actions, malformed +timestamps, non-array histories, and histories larger than 500 events. + +## Outputs + +`buildOwnershipLedger` returns: + +```text +status: ready +activeOwners: current owner summaries sorted by messageId +conflicts: rejected claim, release, or transfer attempts +summary: totalEvents, activeOwners, conflicts +``` + +`createOwnershipState` returns one of: + +- `loading` while a future adapter is loading history. +- `empty` when no active owners exist. +- `ready` when active owner summaries are available. +- `error` when validation or future adapter work fails. + +## Core Behavior + +- `claim` assigns an unowned message to an owner. +- duplicate claims by the same owner are idempotent history updates. +- claims by a different owner produce an `already-owned` conflict. +- `release` clears ownership only when requested by the current owner. +- `transfer` moves ownership only when requested by the current owner. +- all returned events and ledger snapshots are defensive copies. + +## Review Notes + +This issue adds only a local core engine. Future UI and integration issues should +call the engine through folder-local adapters and must document any product data +ownership before writing to inbox state, notifications, persistence, or analytics. diff --git a/tools/v1/team/email-ownership-tracker/fixtures/ownership-engine-cases.json b/tools/v1/team/email-ownership-tracker/fixtures/ownership-engine-cases.json new file mode 100644 index 00000000..e6895aa8 --- /dev/null +++ b/tools/v1/team/email-ownership-tracker/fixtures/ownership-engine-cases.json @@ -0,0 +1,87 @@ +{ + "events": [ + { + "eventId": "evt-001", + "messageId": "msg-001@example.test", + "action": "claim", + "actorEmail": "alice@example.test", + "ownerEmail": "alice@example.test", + "subject": "Enterprise billing renewal", + "reason": "Alice starts the first customer response.", + "createdAt": "2026-07-01T09:00:00.000Z" + }, + { + "eventId": "evt-002", + "messageId": "msg-002@example.test", + "action": "claim", + "actorEmail": "bob@example.test", + "ownerEmail": "bob@example.test", + "subject": "Support escalation", + "reason": "Bob handles the escalation thread.", + "createdAt": "2026-07-01T09:05:00.000Z" + }, + { + "eventId": "evt-003", + "messageId": "msg-001@example.test", + "action": "claim", + "actorEmail": "carol@example.test", + "ownerEmail": "carol@example.test", + "reason": "Carol attempts to claim an already owned thread.", + "createdAt": "2026-07-01T09:10:00.000Z" + }, + { + "eventId": "evt-004", + "messageId": "msg-002@example.test", + "action": "transfer", + "actorEmail": "bob@example.test", + "ownerEmail": "bob@example.test", + "nextOwnerEmail": "dana@example.test", + "reason": "Dana takes over the escalation.", + "createdAt": "2026-07-01T09:15:00.000Z" + }, + { + "eventId": "evt-005", + "messageId": "msg-001@example.test", + "action": "release", + "actorEmail": "alice@example.test", + "ownerEmail": "alice@example.test", + "reason": "Alice releases after handoff.", + "createdAt": "2026-07-01T09:20:00.000Z" + } + ], + "invalidEvents": [ + { + "id": "bad-message-id", + "event": { + "eventId": "evt-bad-001", + "messageId": "../private", + "action": "claim", + "actorEmail": "alice@example.test", + "createdAt": "2026-07-01T09:00:00.000Z" + }, + "code": "invalid-id" + }, + { + "id": "bad-email", + "event": { + "eventId": "evt-bad-002", + "messageId": "msg-003@example.test", + "action": "claim", + "actorEmail": "alice@example.test\r\nBcc: victim@example.test", + "createdAt": "2026-07-01T09:00:00.000Z" + }, + "code": "invalid-email" + }, + { + "id": "bad-action", + "event": { + "eventId": "evt-bad-003", + "messageId": "msg-004@example.test", + "action": "steal", + "actorEmail": "alice@example.test", + "createdAt": "2026-07-01T09:00:00.000Z" + }, + "code": "invalid-action" + } + ] +} diff --git a/tools/v1/team/email-ownership-tracker/tests/ownership-engine.test.mjs b/tools/v1/team/email-ownership-tracker/tests/ownership-engine.test.mjs new file mode 100644 index 00000000..9d8753e1 --- /dev/null +++ b/tools/v1/team/email-ownership-tracker/tests/ownership-engine.test.mjs @@ -0,0 +1,184 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + LIMITS, + OwnershipEngineError, + buildOwnershipLedger, + createOwnershipEngine, + createOwnershipState, + normalizeEmail, + normalizeId, + normalizeOwnershipEvent, +} from "../core/ownership-engine.mjs"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const fixture = JSON.parse( + readFileSync(join(__dirname, "..", "fixtures", "ownership-engine-cases.json"), "utf8"), +); + +function claim(overrides = {}) { + return { + eventId: "evt-test-001", + messageId: "msg-test-001@example.test", + action: "claim", + actorEmail: "owner@example.test", + ownerEmail: "owner@example.test", + createdAt: "2026-07-01T09:00:00.000Z", + reason: "Initial claim", + ...overrides, + }; +} + +describe("normalizers", () => { + it("normalizes safe ids and rejects unsafe ids", () => { + assert.equal(normalizeId(" msg-001@example.test ", "messageId"), "msg-001@example.test"); + assert.throws(() => normalizeId("../private", "messageId"), OwnershipEngineError); + assert.throws( + () => normalizeId("a".repeat(LIMITS.MAX_MESSAGE_ID_LENGTH + 1), "messageId"), + OwnershipEngineError, + ); + }); + + it("normalizes safe emails and rejects injected emails", () => { + assert.equal(normalizeEmail(" Owner@Example.Test "), "owner@example.test"); + assert.throws( + () => normalizeEmail("owner@example.test\r\nBcc: x@example.test"), + OwnershipEngineError, + ); + }); + + it("normalizes ownership events", () => { + const event = normalizeOwnershipEvent( + claim({ ownerEmail: "Owner@Example.Test", subject: "Hello" }), + ); + assert.equal(event.ownerEmail, "owner@example.test"); + assert.equal(event.subject, "Hello"); + assert.equal(event.createdAt, "2026-07-01T09:00:00.000Z"); + }); + + it("rejects non-UTC timestamp shapes", () => { + assert.throws( + () => normalizeOwnershipEvent(claim({ createdAt: "2026-07-01" })), + OwnershipEngineError, + ); + }); + + it("rejects fixture invalid events with stable error codes", () => { + for (const entry of fixture.invalidEvents) { + assert.throws( + () => normalizeOwnershipEvent(entry.event), + (error) => error instanceof OwnershipEngineError && error.code === entry.code, + ); + } + }); +}); + +describe("buildOwnershipLedger", () => { + it("derives active owners and conflicts from fixture history", () => { + const ledger = buildOwnershipLedger(fixture.events); + + assert.equal(ledger.status, "ready"); + assert.equal(ledger.summary.totalEvents, 5); + assert.equal(ledger.summary.activeOwners, 1); + assert.equal(ledger.summary.conflicts, 1); + assert.equal(ledger.activeOwners[0].messageId, "msg-002@example.test"); + assert.equal(ledger.activeOwners[0].ownerEmail, "dana@example.test"); + assert.equal(ledger.conflicts[0].code, "already-owned"); + }); + + it("treats duplicate claims by the same owner as applied history", () => { + const ledger = buildOwnershipLedger([ + claim({ eventId: "evt-1" }), + claim({ eventId: "evt-2", createdAt: "2026-07-01T09:01:00.000Z" }), + ]); + + assert.equal(ledger.summary.conflicts, 0); + assert.equal(ledger.activeOwners[0].history.length, 2); + }); + + it("records release and transfer conflicts without changing the current owner", () => { + const ledger = buildOwnershipLedger([ + claim({ eventId: "evt-1", ownerEmail: "owner@example.test" }), + claim({ + eventId: "evt-2", + action: "release", + actorEmail: "other@example.test", + ownerEmail: "other@example.test", + createdAt: "2026-07-01T09:01:00.000Z", + }), + claim({ + eventId: "evt-3", + action: "transfer", + actorEmail: "other@example.test", + ownerEmail: "other@example.test", + nextOwnerEmail: "next@example.test", + createdAt: "2026-07-01T09:02:00.000Z", + }), + ]); + + assert.equal(ledger.summary.conflicts, 2); + assert.equal(ledger.activeOwners[0].ownerEmail, "owner@example.test"); + }); + + it("rejects non-array and oversized histories", () => { + assert.throws(() => buildOwnershipLedger(null), OwnershipEngineError); + assert.throws( + () => buildOwnershipLedger(new Array(LIMITS.MAX_EVENTS + 1).fill(claim())), + OwnershipEngineError, + ); + }); +}); + +describe("createOwnershipState", () => { + it("maps loading, empty, ready, and error states", () => { + assert.equal(createOwnershipState(null, { loading: true }).status, "loading"); + assert.equal(createOwnershipState(buildOwnershipLedger([])).status, "empty"); + assert.equal(createOwnershipState(buildOwnershipLedger([claim()])).status, "ready"); + + const errorState = createOwnershipState(null, { + error: new OwnershipEngineError("bad-input", "Bad input"), + }); + assert.equal(errorState.status, "error"); + assert.equal(errorState.code, "bad-input"); + }); + + it("returns defensive copies for ready state", () => { + const ready = createOwnershipState(buildOwnershipLedger([claim()])); + ready.activeOwners[0].ownerEmail = "mutated@example.test"; + + const next = createOwnershipState(buildOwnershipLedger([claim()])); + assert.equal(next.activeOwners[0].ownerEmail, "owner@example.test"); + }); +}); + +describe("createOwnershipEngine", () => { + it("provides a deterministic folder-local service surface", () => { + const engine = createOwnershipEngine([], { + now: () => "2026-07-01T10:00:00.000Z", + idPrefix: "test-event", + }); + + engine.claim("msg-1@example.test", "alice@example.test", { subject: "First" }); + engine.transfer("msg-1@example.test", "alice@example.test", "bob@example.test"); + + const events = engine.listEvents(); + const ledger = engine.getLedger(); + + assert.equal(events[0].eventId, "test-event-001"); + assert.equal(events[1].eventId, "test-event-002"); + assert.equal(ledger.status, "ready"); + assert.equal(ledger.activeOwners[0].ownerEmail, "bob@example.test"); + }); + + it("keeps event storage private by returning copies", () => { + const engine = createOwnershipEngine([claim()]); + const events = engine.listEvents(); + events[0].ownerEmail = "mutated@example.test"; + + assert.equal(engine.listEvents()[0].ownerEmail, "owner@example.test"); + }); +});