diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..93f2890bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Add a versioned, dependency-free BandScope → naruon rehearsal handoff contract with Band norm-group identity, Event and Commitment semantics, calibrated provenance, deterministic JSON serialization, and a public JSON Schema while preserving BandScope's standalone local-first operation. Unknown-field errors retain the structural object path without echoing caller-controlled field names. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. diff --git a/docs/doctoring/naruon-rehearsal-handoff.md b/docs/doctoring/naruon-rehearsal-handoff.md new file mode 100644 index 000000000..3c638f87d --- /dev/null +++ b/docs/doctoring/naruon-rehearsal-handoff.md @@ -0,0 +1,74 @@ +# BandScope → naruon rehearsal handoff standards evidence + +## Status + +**Active Draft PR evidence.** This record documents the standards basis for the versioned BandScope → naruon rehearsal handoff introduced on PR #737. It is not protected-`develop` shipped truth until the implementation is merged and revalidated on the protected branch. + +## Scope and architectural decision + +BandScope remains independently useful and local-first. The integration boundary is a dependency-free, versioned JSON artifact rather than a mandatory naruon network dependency. The TypeScript parser is the authoritative application trust boundary; the public JSON Schema is a portable structural companion, not a substitute for application-level semantic validation. + +This split is intentional. JSON Schema Draft 2020-12 separates structural validation from semantic `format` handling, and its standard meta-schema does not require `format` to be asserted by default. Therefore, consumers cannot safely assume that a generic schema validator will fully validate date-time semantics merely because a property declares `"format": "date-time"` (Wright et al., 2022). BandScope consequently performs calendrical, IANA-zone, cross-field, size, snapshot, and offset-consistency checks in the TypeScript parser. + +## Timestamp profile and time-zone semantics + +The handoff uses a deliberately bounded RFC 3339-derived timestamp profile: + +- four-digit years, two-digit month/day/time fields, an explicit `T`, and an explicit `Z` or numeric UTC offset; +- calendar-valid dates and bounded fractional seconds; +- uppercase `T`/`Z` canonical spelling; +- no leap-second `:60` values at this application boundary; and +- an explicit IANA time-zone identifier stored separately in `event.timeZone`. + +RFC 3339 permits leap-second `:60` under its leap-second rules and notes that lowercase `t`/`z` can be accepted by the ABNF, while also allowing specifications in case-sensitive contexts to require uppercase spellings (Klyne & Newman, 2002). BandScope intentionally chooses a narrower scheduling profile: rehearsal events do not need leap-second representation, and canonical uppercase serialization avoids cross-runtime ambiguity. Documentation must therefore describe this as BandScope's RFC 3339 profile rather than implying acceptance of every RFC 3339 lexical form. + +RFC 9557 updates RFC 3339's interpretation of `Z`: `Z` expresses that the UTC instant is known while the preferred local offset is not asserted; `-00:00` has the same semantic meaning but is less interoperable and `Z` is preferred. By contrast, a numeric offset is an assertion that can be inconsistent with named time-zone information (Sharma & Bormann, 2024). The handoff reflects that distinction: + +- `Z` and `-00:00` do not assert local clock fields against `event.timeZone`; +- numeric `+/-HH:MM` offsets are checked against the required IANA zone at that instant; and +- an inconsistency is rejected rather than silently choosing one source of temporal truth. + +Although RFC 9557 serializes named time zones as IXDTF suffixes, BandScope carries the IANA identifier in a separate required JSON field. The semantic rule is deliberately equivalent to treating the named zone as critical application information: a consumer must not project an event whose asserted numeric offset conflicts with the required zone. + +## JSON and schema boundary + +RFC 8259 defines JSON's interoperable data model and requires object member names to be strings; it does not provide application authorization, provenance, or semantic identity guarantees (Bray, 2017). The handoff therefore adds fail-closed application constraints beyond JSON syntax: + +- `additionalProperties: false` at every public object level; +- bounded strings, arrays, and serialized UTF-8 size; +- opaque nonnumeric identifiers; +- exact artifact kind/version discriminators; +- canonical key order for deterministic serialization; +- band identity consistency between `source.bandId` and `normGroup.id`; +- finite calibrated confidence in `[0, 1]`; +- dense bounded evidence receipts; and +- payload-safe diagnostics that report schema-owned locations without echoing attacker-controlled unknown property names. + +JSON Schema Draft 2020-12 expects Unicode-aware regular-expression behavior, but validator implementations can still differ in feature support. The checked-in schema therefore documents that consumers must use Unicode semantics for `\p{Nd}` and must invoke the TypeScript parser for semantic checks that are not portable schema assertions. + +## Security, privacy, and evidence implications + +The artifact carries authorized rehearsal coordination facts and provenance; it is not itself an authorization token. It grants no filesystem, database, calendar, mail, model, or network capability. Connector authentication, tenant binding, consent, signature/authenticated-envelope verification, persistence, and externally visible writeback remain outside the shared-types package. + +For audit readiness, a receiving connector should preserve the validated artifact plus transport/signature evidence, maintain tenant/band segregation, and record the mapping from `sourceRecordId`/field evidence to any projected Event or Commitment. Validation errors must remain payload-safe so logs do not become a secondary disclosure channel for person, tenant, credential, or other caller-controlled property names. + +## Verification contract + +Commercial verification for this boundary requires all of the following: + +1. runtime parser and public schema reject unknown fields and malformed structure; +2. runtime parser enforces the semantic rules the schema cannot portably guarantee, including IANA-zone availability, numeric-offset consistency, cross-field identity, canonical snapshotting, and serialized-size limits; +3. deterministic serialization is invariant to caller key insertion order; +4. valid `Z`/`-00:00` unknown-local-offset forms remain accepted with an explicit IANA zone, while inconsistent numeric offsets fail closed; +5. the deliberately narrower timestamp profile rejects leap-second `:60` and noncanonical lowercase timestamp separators as application policy rather than misclassifying those forms as universally invalid RFC 3339; and +6. repository exact-head type, lint, test, coverage, SAST, security, SBOM, supply-chain, and independent-review gates remain mandatory. + +## References + +Bray, T. (2017). *The JavaScript Object Notation (JSON) data interchange format* (RFC 8259). Internet Engineering Task Force. https://doi.org/10.17487/RFC8259 + +Klyne, G., & Newman, C. (2002). *Date and time on the Internet: Timestamps* (RFC 3339). Internet Engineering Task Force. https://doi.org/10.17487/RFC3339 + +Sharma, U., & Bormann, C. (2024). *Date and time on the Internet: Timestamps with additional information* (RFC 9557). Internet Engineering Task Force. https://doi.org/10.17487/RFC9557 + +Wright, A., Andrews, H., Hutton, B., & Dennis, G. (2022). *JSON Schema Draft 2020-12*. JSON Schema. https://json-schema.org/draft/2020-12 diff --git a/docs/integrations/naruon-rehearsal-handoff-v1.schema.json b/docs/integrations/naruon-rehearsal-handoff-v1.schema.json new file mode 100644 index 000000000..8b1049a4d --- /dev/null +++ b/docs/integrations/naruon-rehearsal-handoff-v1.schema.json @@ -0,0 +1,157 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://contextualwisdomlab.github.io/bandscope/schemas/naruon-rehearsal-handoff-v1.schema.json", + "title": "BandScope naruon rehearsal handoff v1", + "description": "A network-agnostic BandScope export that contributes a Band norm-group, rehearsal Event, commitment status, RSVP direction, and provenance to naruon.", + "type": "object", + "additionalProperties": false, + "required": [ + "artifactKind", + "artifactVersion", + "createdAt", + "source", + "normGroup", + "event", + "commitment", + "provenance" + ], + "properties": { + "artifactKind": { + "const": "bandscope.naruon.rehearsal-event" + }, + "artifactVersion": { + "const": 1 + }, + "createdAt": { + "$ref": "#/$defs/timestamp" + }, + "source": { + "type": "object", + "additionalProperties": false, + "required": ["application", "workspaceId", "bandId", "rehearsalId"], + "properties": { + "application": { + "const": "bandscope" + }, + "workspaceId": { + "$ref": "#/$defs/opaqueIdentifier" + }, + "bandId": { + "$ref": "#/$defs/opaqueIdentifier" + }, + "rehearsalId": { + "$ref": "#/$defs/opaqueIdentifier" + } + } + }, + "normGroup": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "id", "label"], + "properties": { + "kind": { + "const": "band" + }, + "id": { + "$ref": "#/$defs/opaqueIdentifier" + }, + "label": { + "$ref": "#/$defs/displayText" + } + } + }, + "event": { + "type": "object", + "additionalProperties": false, + "required": ["title", "startsAt", "endsAt", "timeZone"], + "properties": { + "title": { + "$ref": "#/$defs/displayText" + }, + "startsAt": { + "$ref": "#/$defs/timestamp" + }, + "endsAt": { + "$ref": "#/$defs/timestamp" + }, + "timeZone": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!\\s)(?!.*\\s$)[^\\u0000-\\u001f\\u007f]+$" + }, + "venue": { + "$ref": "#/$defs/displayText" + } + } + }, + "commitment": { + "type": "object", + "additionalProperties": false, + "required": ["status", "rsvpDirection"], + "properties": { + "status": { + "enum": ["confirmed", "tentative", "desired"] + }, + "rsvpDirection": { + "enum": ["organizer", "attendee"] + } + } + }, + "provenance": { + "type": "object", + "additionalProperties": false, + "required": ["sourceRecordId", "confidence", "evidence"], + "properties": { + "sourceRecordId": { + "$ref": "#/$defs/opaqueIdentifier" + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "evidence": { + "type": "array", + "minItems": 1, + "maxItems": 64, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["field", "value"], + "properties": { + "field": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^(?!\\s)(?!.*\\s$)[^\\u0000-\\u001f\\u007f]+$" + }, + "value": { + "$ref": "#/$defs/displayText" + } + } + } + } + } + } + }, + "$defs": { + "opaqueIdentifier": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^(?!\\p{Nd}+$)(?!\\s)(?!.*\\s$)[^\\u0000-\\u001f\\u007f]+$" + }, + "displayText": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "pattern": "^(?!\\s)(?!.*\\s$)[^\\u0000-\\u001f\\u007f]+$" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "pattern": "^[0-9]{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])T(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](?:\\.[0-9]{1,9})?(?:Z|[+-](?:[01][0-9]|2[0-3]):[0-5][0-9])$" + } + } +} diff --git a/docs/integrations/naruon.md b/docs/integrations/naruon.md new file mode 100644 index 000000000..d1ba7935e --- /dev/null +++ b/docs/integrations/naruon.md @@ -0,0 +1,115 @@ +# BandScope → naruon rehearsal handoff + +BandScope remains a fully standalone, local-first desktop application. The naruon bridge is an **explicit export contract**, not a mandatory network dependency: BandScope can produce a versioned JSON artifact, and a separately authorized naruon connector may ingest that artifact as a Band norm-group, rehearsal Event, and status-bearing Commitment. + +## Product outcome + +The bridge closes the first BandScope side of the platform vertical described by `ContextualWisdomLab/bandscope#610` without coupling the desktop app to naruon internals. + +A naruon deployment can use the artifact to: + +- identify the band as an overlapping norm/reference group; +- place a rehearsal on the shared Event graph; +- preserve `confirmed`, `tentative`, or `desired` commitment strength; +- preserve organizer-versus-attendee RSVP direction; +- run status-weighted conflict detection without silently breaking a confirmed commitment; +- cite the BandScope source record and field-level evidence; +- calibrate downstream behavior from an explicit `0..1` confidence value. + +BandScope itself continues to analyze songs and manage rehearsal material even when naruon is absent, offline, or intentionally disabled. + +## TypeScript API + +The dependency-free contract is exported from a stable package subpath: + +```ts +import { + createNaruonRehearsalHandoff, + serializeNaruonRehearsalHandoff +} from "@bandscope/shared-types/naruon"; + +const artifact = createNaruonRehearsalHandoff({ + createdAt: "2026-08-03T01:23:45Z", + source: { + application: "bandscope", + workspaceId: "workspace-local-alpha", + bandId: "band-contextual-wisdom", + rehearsalId: "rehearsal-2026-08-10" + }, + normGroup: { + kind: "band", + id: "band-contextual-wisdom", + label: "Contextual Wisdom Band" + }, + event: { + title: "August rehearsal", + startsAt: "2026-08-10T19:00:00+09:00", + endsAt: "2026-08-10T21:30:00+09:00", + timeZone: "Asia/Seoul", + venue: "Studio A" + }, + commitment: { + status: "confirmed", + rsvpDirection: "organizer" + }, + provenance: { + sourceRecordId: "calendar-record-alpha", + confidence: 0.94, + evidence: [ + { field: "startsAt", value: "2026-08-10T19:00:00+09:00" }, + { field: "venue", value: "Studio A" } + ] + } +}); + +const json = serializeNaruonRehearsalHandoff(artifact); +``` + +Consumers receiving untrusted bytes must call `deserializeNaruonRehearsalHandoff` or `parseNaruonRehearsalHandoff` before use. Serialized handoffs are limited to 256 KiB of UTF-8 and are size-checked before JSON parsing. Preserve original transport bytes separately only when a detached-signature verification workflow requires them; application logic should use the validated canonical value. + +## Boundary guarantees + +The contract fails closed on: + +- unknown fields at every object level; +- numeric-only IDs (BandScope and naruon IDs must remain opaque strings); +- blank, untrimmed, control-character-bearing, or oversized values; +- malformed or calendrically invalid timestamps outside BandScope's canonical RFC 3339 profile; +- leap-second `:60` values and noncanonical lowercase timestamp separators, which this scheduling contract deliberately excludes even though RFC 3339 itself defines broader lexical cases; +- an end time that is not later than its start time; +- time-zone identifiers rejected by the runtime's IANA/ICU database; +- numeric UTC offsets whose asserted local clock fields disagree with the required IANA time zone at that instant, including daylight-saving transitions; +- a norm-group identity that differs from the exported source band identity; +- unsupported commitment status or RSVP direction; +- non-finite or out-of-range confidence values; +- empty, sparse, oversized, or malformed provenance receipt arrays; +- JSON inputs larger than 256 KiB of UTF-8 or caller-owned values that cannot be safely snapshotted. + +`Z` and `-00:00` are accepted with an explicit IANA zone as unknown-local-offset forms; a numeric `+/-HH:MM` offset is treated as an assertion and must agree with that zone. This follows RFC 9557's update to RFC 3339: `Z` and `-00:00` do not assert a preferred local offset, while a numeric offset does. + +Validation errors identify the structural object containing an unknown field but never echo the caller-controlled field name. This preserves actionable location without copying tenant, person, credential, or other payload text into logs. Diagnostic locations such as `root`, `source`, and `provenance.evidence[0]` are schema-owned labels derived from validation structure, not from payload keys or values. + +Parsing snapshots caller-owned data once before validation and canonicalization. It then returns newly allocated nested objects and evidence receipts, so accessors, proxies, concurrent mutation, or retained input references cannot make validation observe different data from the canonical output. + +The standards rationale, semantic profile decisions, and APA 7 references are maintained in `docs/doctoring/naruon-rehearsal-handoff.md`. + +## Trust and privacy model + +This artifact contains **rehearsal coordination facts only**. It does not grant naruon filesystem, database, calendar, mail, model, or network authority. Transport, tenant authorization, detached signature verification, consent, context bridging, persistence, and writeback remain responsibilities of the naruon plugin/connector installation. + +A connector should: + +1. authenticate the producing BandScope installation and intended naruon tenant; +2. verify a detached signature or authenticated transport envelope; +3. parse the artifact using this contract; +4. persist provenance before projecting Event/Commitment candidates; +5. keep per-band context segregated by default; +6. require explicit approval before any externally visible decline, reschedule, or CalDAV writeback. + +## Compatibility + +- `artifactKind`: `bandscope.naruon.rehearsal-event` +- `artifactVersion`: `1` +- Additive fields require a new version because version 1 rejects unknown keys. +- Breaking semantic changes require a new artifact kind or major version. +- The JSON Schema companion is `naruon-rehearsal-handoff-v1.schema.json`; validators must compile schema patterns with Unicode semantics for `\p{Nd}`. Under JSON Schema Draft 2020-12, `format` is not an assertion by default, so schema-only consumers must not treat `"format": "date-time"` as proof of complete temporal validity. The TypeScript parser remains authoritative for payload size, snapshotting, cross-field rules, leading/trailing whitespace normalization, Unicode-aware numeric-only identifier rejection, BandScope's canonical timestamp profile, RFC 9557 offset/time-zone consistency, and IANA time-zone checks. diff --git a/packages/shared-types/package.json b/packages/shared-types/package.json index f03474284..0f849a36a 100644 --- a/packages/shared-types/package.json +++ b/packages/shared-types/package.json @@ -2,7 +2,10 @@ "name": "@bandscope/shared-types", "version": "0.1.0", "type": "module", - "exports": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./naruon": "./src/naruon.ts" + }, "scripts": { "lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"", "typecheck": "tsc --noEmit", diff --git a/packages/shared-types/src/naruon.ts b/packages/shared-types/src/naruon.ts new file mode 100644 index 000000000..0d501d354 --- /dev/null +++ b/packages/shared-types/src/naruon.ts @@ -0,0 +1,498 @@ +export /** + * Stable artifact kind emitted by BandScope for naruon ingestion. + */ +const NARUON_REHEARSAL_HANDOFF_KIND = "bandscope.naruon.rehearsal-event" as const; + +export /** + * Current additive schema version for the naruon rehearsal handoff. + */ +const NARUON_REHEARSAL_HANDOFF_VERSION = 1 as const; + +export /** + * Maximum number of provenance receipts accepted in one handoff. + */ +const MAX_NARUON_EVIDENCE_RECEIPTS = 64; + +export /** + * Maximum UTF-8 size accepted before untrusted JSON parsing. + */ +const MAX_NARUON_SERIALIZED_BYTES = 262_144; + +const MAX_IDENTIFIER_LENGTH = 256; +const MAX_DISPLAY_TEXT_LENGTH = 2_048; +const MAX_TIME_ZONE_LENGTH = 128; +const RFC3339_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?(Z|[+-](\d{2}):(\d{2}))$/; +const COMMITMENT_STATUSES = ["confirmed", "tentative", "desired"] as const; +const RSVP_DIRECTIONS = ["organizer", "attendee"] as const; + +/** Commitment strength used by naruon's status-weighted conflict resolver. */ +export type NaruonCommitmentStatus = (typeof COMMITMENT_STATUSES)[number]; + +/** Whether the BandScope user organizes or attends the rehearsal. */ +export type NaruonRsvpDirection = (typeof RSVP_DIRECTIONS)[number]; + +/** Field-level source receipt included in a naruon handoff. */ +export type NaruonEvidenceReceipt = { + field: string; + value: string; +}; + +/** Local BandScope identity and tenancy information for a handoff. */ +export type NaruonHandoffSource = { + application: "bandscope"; + workspaceId: string; + bandId: string; + rehearsalId: string; +}; + +/** Band norm-group contributed to naruon's shared knowledge graph. */ +export type NaruonBandNormGroup = { + kind: "band"; + id: string; + label: string; +}; + +/** Scheduled rehearsal event represented independently of any calendar vendor. */ +export type NaruonRehearsalEvent = { + title: string; + startsAt: string; + endsAt: string; + timeZone: string; + venue?: string; +}; + +/** Commitment metadata required for status-weighted conflict resolution. */ +export type NaruonRehearsalCommitment = { + status: NaruonCommitmentStatus; + rsvpDirection: NaruonRsvpDirection; +}; + +/** Auditable evidence and calibrated confidence for the exported event. */ +export type NaruonHandoffProvenance = { + sourceRecordId: string; + confidence: number; + evidence: NaruonEvidenceReceipt[]; +}; + +/** + * Versioned, network-agnostic BandScope artifact that naruon can ingest as a + * Band norm-group, rehearsal Event, and status-bearing Commitment. + */ +export type NaruonRehearsalHandoff = { + artifactKind: typeof NARUON_REHEARSAL_HANDOFF_KIND; + artifactVersion: typeof NARUON_REHEARSAL_HANDOFF_VERSION; + createdAt: string; + source: NaruonHandoffSource; + normGroup: NaruonBandNormGroup; + event: NaruonRehearsalEvent; + commitment: NaruonRehearsalCommitment; + provenance: NaruonHandoffProvenance; +}; + +/** Input accepted by the canonical handoff builder. */ +export type CreateNaruonRehearsalHandoffInput = Omit< + NaruonRehearsalHandoff, + "artifactKind" | "artifactVersion" +>; + +/** Result of stabilizing one caller-owned value at the trust boundary. */ +type BoundarySnapshot = + | { ok: true; value: unknown } + | { ok: false; error: string }; + +/** Snapshot caller-owned data so validation and canonicalization see one value. */ +function snapshotBoundaryValue(value: unknown): BoundarySnapshot { + try { + return { ok: true, value: structuredClone(value) }; + } catch { + return { ok: false, error: "root is not structured-cloneable" }; + } +} + +/** Return whether a stabilized value is a plain non-array object. */ +function isRecord(value: unknown): value is Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + return Object.getPrototypeOf(value) === Object.prototype; +} + +/** Return whether an array is bounded and has every numeric index materialized. */ +function isDenseArray(value: unknown, maximumLength: number): value is unknown[] { + if (!Array.isArray(value)) return false; + const length = Number(value.length); + if (!Number.isSafeInteger(length) || length > maximumLength) return false; + for (let index = 0; index < length; index += 1) { + if (!(index in value)) return false; + } + return true; +} + +/** Return the structural parent path when an object contains an unknown key. */ +function unexpectedKey( + value: Record, + allowedKeys: readonly string[], + path: string +): string | null { + for (const key of Object.keys(value)) { + if (!allowedKeys.includes(key)) { + return path; + } + } + return null; +} + +/** Return whether text is printable, trimmed, non-empty, and bounded. */ +function isDisplayText(value: unknown, maximumLength = MAX_DISPLAY_TEXT_LENGTH): value is string { + return ( + typeof value === "string" && + value.length > 0 && + value.length <= maximumLength && + value === value.trim() && + // The public boundary deliberately rejects C0 and DEL controls. + // eslint-disable-next-line no-control-regex + !/[\u0000-\u001f\u007f]/u.test(value) + ); +} + +/** Return whether an identifier is opaque rather than numeric or user-facing. */ +function isOpaqueIdentifier(value: unknown): value is string { + return ( + isDisplayText(value, MAX_IDENTIFIER_LENGTH) && + !/^\p{Decimal_Number}+$/u.test(value) + ); +} + +/** Return whether a value belongs to a readonly string enum. */ +function isOneOf(values: readonly T[], value: unknown): value is T { + return values.includes(value as T); +} + +/** Return the proleptic-Gregorian number of days in one month. */ +function daysInMonth(year: number, month: number): number { + if (month === 2) { + const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + return leapYear ? 29 : 28; + } + return [4, 6, 9, 11].includes(month) ? 30 : 31; +} + +/** Return whether an RFC 3339 timestamp is both syntactically and calendrically valid. */ +function isRfc3339(value: unknown): value is string { + if (typeof value !== "string") return false; + const match = RFC3339_PATTERN.exec(value); + if (!match) return false; + + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const hour = Number(match[4]); + const minute = Number(match[5]); + const second = Number(match[6]); + const offsetHour = match[8] === undefined ? 0 : Number(match[8]); + const offsetMinute = match[9] === undefined ? 0 : Number(match[9]); + if ( + month < 1 || + month > 12 || + day < 1 || + day > daysInMonth(year, month) || + hour > 23 || + minute > 59 || + second > 59 || + offsetHour > 23 || + offsetMinute > 59 + ) { + return false; + } + return Number.isFinite(Date.parse(value)); +} + +/** Return whether a time-zone identifier is accepted by the host ICU database. */ +function isTimeZone(value: unknown): value is string { + if (!isDisplayText(value, MAX_TIME_ZONE_LENGTH)) return false; + try { + new Intl.DateTimeFormat("en", { timeZone: value }).format(0); + return true; + } catch { + return false; + } +} + +/** Return whether a timestamp's asserted local fields agree with its critical IANA zone. */ +function isOffsetConsistentWithTimeZone(timestamp: string, timeZone: string): boolean { + const match = RFC3339_PATTERN.exec(timestamp) as RegExpExecArray; + if (match[7] === "Z" || match[7] === "-00:00") return true; + const parts = Object.fromEntries( + new Intl.DateTimeFormat("en-US-u-ca-iso8601-nu-latn", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23" + }) + .formatToParts(new Date(timestamp)) + .filter((part) => part.type !== "literal") + .map((part) => [part.type, part.value]) + ); + return ( + String(parts.year).padStart(4, "0") === match[1] && + parts.month === match[2] && + parts.day === match[3] && + parts.hour === match[4] && + parts.minute === match[5] && + parts.second === match[6] + ); +} + +/** Validate one source receipt. */ +function validateEvidenceReceipt(value: unknown, path: string): string | null { + if (!isRecord(value)) return `${path} must be an object`; + const extra = unexpectedKey(value, ["field", "value"], path); + if (extra) return `${extra} contains an unexpected field`; + if (!isDisplayText(value.field, MAX_IDENTIFIER_LENGTH)) return `${path}.field is invalid`; + if (!isDisplayText(value.value)) return `${path}.value is invalid`; + return null; +} + +/** Validate one stable boundary snapshot without rereading caller-owned values. */ +function validateSnapshot(value: unknown): string | null { + if (!isRecord(value)) return "root must be an object"; + const rootExtra = unexpectedKey( + value, + [ + "artifactKind", + "artifactVersion", + "createdAt", + "source", + "normGroup", + "event", + "commitment", + "provenance" + ], + "root" + ); + if (rootExtra) return `${rootExtra} contains an unexpected field`; + if (value.artifactKind !== NARUON_REHEARSAL_HANDOFF_KIND) return "artifactKind is invalid"; + if (value.artifactVersion !== NARUON_REHEARSAL_HANDOFF_VERSION) return "artifactVersion is invalid"; + if (!isRfc3339(value.createdAt)) return "createdAt is invalid"; + + if (!isRecord(value.source)) return "source must be an object"; + const sourceExtra = unexpectedKey( + value.source, + ["application", "workspaceId", "bandId", "rehearsalId"], + "source" + ); + if (sourceExtra) return `${sourceExtra} contains an unexpected field`; + if (value.source.application !== "bandscope") return "source.application is invalid"; + for (const field of ["workspaceId", "bandId", "rehearsalId"] as const) { + if (!isOpaqueIdentifier(value.source[field])) return `source.${field} is invalid`; + } + + if (!isRecord(value.normGroup)) return "normGroup must be an object"; + const normExtra = unexpectedKey(value.normGroup, ["kind", "id", "label"], "normGroup"); + if (normExtra) return `${normExtra} contains an unexpected field`; + if (value.normGroup.kind !== "band") return "normGroup.kind is invalid"; + if (!isOpaqueIdentifier(value.normGroup.id)) return "normGroup.id is invalid"; + if (!isDisplayText(value.normGroup.label)) return "normGroup.label is invalid"; + if (value.normGroup.id !== value.source.bandId) return "normGroup.id must equal source.bandId"; + + if (!isRecord(value.event)) return "event must be an object"; + const eventExtra = unexpectedKey( + value.event, + ["title", "startsAt", "endsAt", "timeZone", "venue"], + "event" + ); + if (eventExtra) return `${eventExtra} contains an unexpected field`; + if (!isDisplayText(value.event.title)) return "event.title is invalid"; + if (!isRfc3339(value.event.startsAt)) return "event.startsAt is invalid"; + if (!isRfc3339(value.event.endsAt)) return "event.endsAt is invalid"; + if (Date.parse(value.event.endsAt) <= Date.parse(value.event.startsAt)) { + return "event.endsAt must be later than event.startsAt"; + } + if (!isTimeZone(value.event.timeZone)) return "event.timeZone is invalid"; + if (!isOffsetConsistentWithTimeZone(value.event.startsAt, value.event.timeZone)) { + return "event.startsAt offset is inconsistent with event.timeZone"; + } + if (!isOffsetConsistentWithTimeZone(value.event.endsAt, value.event.timeZone)) { + return "event.endsAt offset is inconsistent with event.timeZone"; + } + if (value.event.venue !== undefined && !isDisplayText(value.event.venue)) { + return "event.venue is invalid"; + } + + if (!isRecord(value.commitment)) return "commitment must be an object"; + const commitmentExtra = unexpectedKey( + value.commitment, + ["status", "rsvpDirection"], + "commitment" + ); + if (commitmentExtra) return `${commitmentExtra} contains an unexpected field`; + if (!isOneOf(COMMITMENT_STATUSES, value.commitment.status)) { + return "commitment.status is invalid"; + } + if (!isOneOf(RSVP_DIRECTIONS, value.commitment.rsvpDirection)) { + return "commitment.rsvpDirection is invalid"; + } + + if (!isRecord(value.provenance)) return "provenance must be an object"; + const provenanceExtra = unexpectedKey( + value.provenance, + ["sourceRecordId", "confidence", "evidence"], + "provenance" + ); + if (provenanceExtra) return `${provenanceExtra} contains an unexpected field`; + if (!isOpaqueIdentifier(value.provenance.sourceRecordId)) { + return "provenance.sourceRecordId is invalid"; + } + if ( + typeof value.provenance.confidence !== "number" || + !Number.isFinite(value.provenance.confidence) || + value.provenance.confidence < 0 || + value.provenance.confidence > 1 + ) { + return "provenance.confidence is invalid"; + } + if ( + !isDenseArray(value.provenance.evidence, MAX_NARUON_EVIDENCE_RECEIPTS) || + value.provenance.evidence.length < 1 + ) { + return "provenance.evidence is invalid"; + } + for (let index = 0; index < value.provenance.evidence.length; index += 1) { + const error = validateEvidenceReceipt( + value.provenance.evidence[index], + `provenance.evidence[${index}]` + ); + if (error) return error; + } + + const canonicalSerialized = `${JSON.stringify(canonicalizeSnapshot(value))}\n`; + if (serializedByteLength(canonicalSerialized) > MAX_NARUON_SERIALIZED_BYTES) { + return "serialized handoff is oversized"; + } + return null; +} + +/** + * Validate an unknown value at the BandScope → naruon trust boundary. + * + * The validator is intentionally side-effect-free and fail-closed. Parsing + * snapshots caller-owned values before validation so validation and + * canonicalization cannot observe different states. + */ +export function validateNaruonRehearsalHandoff(value: unknown): string | null { + const snapshot = snapshotBoundaryValue(value); + return snapshot.ok ? validateSnapshot(snapshot.value) : snapshot.error; +} + +/** Return whether a value satisfies the complete handoff contract. */ +export function isNaruonRehearsalHandoff(value: unknown): value is NaruonRehearsalHandoff { + return validateNaruonRehearsalHandoff(value) === null; +} + +/** Canonicalize one already validated, stable snapshot. */ +function canonicalizeSnapshot(value: Record): NaruonRehearsalHandoff { + const source = value.source as NaruonHandoffSource; + const normGroup = value.normGroup as NaruonBandNormGroup; + const event = value.event as NaruonRehearsalEvent; + const commitment = value.commitment as NaruonRehearsalCommitment; + const provenance = value.provenance as NaruonHandoffProvenance; + return { + artifactKind: NARUON_REHEARSAL_HANDOFF_KIND, + artifactVersion: NARUON_REHEARSAL_HANDOFF_VERSION, + createdAt: value.createdAt as string, + source: { + application: source.application, + workspaceId: source.workspaceId, + bandId: source.bandId, + rehearsalId: source.rehearsalId + }, + normGroup: { + kind: normGroup.kind, + id: normGroup.id, + label: normGroup.label + }, + event: + event.venue === undefined + ? { + title: event.title, + startsAt: event.startsAt, + endsAt: event.endsAt, + timeZone: event.timeZone + } + : { + title: event.title, + startsAt: event.startsAt, + endsAt: event.endsAt, + timeZone: event.timeZone, + venue: event.venue + }, + commitment: { + status: commitment.status, + rsvpDirection: commitment.rsvpDirection + }, + provenance: { + sourceRecordId: provenance.sourceRecordId, + confidence: provenance.confidence, + evidence: provenance.evidence.map((receipt) => ({ + field: receipt.field, + value: receipt.value + })) + } + }; +} + +/** Parse and canonicalize an unknown handoff, throwing on contract violations. */ +export function parseNaruonRehearsalHandoff(value: unknown): NaruonRehearsalHandoff { + const snapshot = snapshotBoundaryValue(value); + if (!snapshot.ok) { + throw new TypeError(`Invalid naruon rehearsal handoff: ${snapshot.error}`); + } + const error = validateSnapshot(snapshot.value); + if (error) { + throw new TypeError(`Invalid naruon rehearsal handoff: ${error}`); + } + return canonicalizeSnapshot(snapshot.value as Record); +} + +/** Build a canonical versioned handoff from application-owned fields. */ +export function createNaruonRehearsalHandoff( + input: CreateNaruonRehearsalHandoffInput +): NaruonRehearsalHandoff { + return parseNaruonRehearsalHandoff({ + ...input, + artifactKind: NARUON_REHEARSAL_HANDOFF_KIND, + artifactVersion: NARUON_REHEARSAL_HANDOFF_VERSION + }); +} + +/** Serialize a validated handoff as deterministic newline-terminated JSON. */ +export function serializeNaruonRehearsalHandoff(value: unknown): string { + return `${JSON.stringify(parseNaruonRehearsalHandoff(value))}\n`; +} + +/** Return the UTF-8 size without allocating for inputs already above the limit. */ +function serializedByteLength(value: string): number { + if (value.length > MAX_NARUON_SERIALIZED_BYTES) return value.length; + return new TextEncoder().encode(value).byteLength; +} + +/** Parse bounded JSON text and validate the resulting handoff at the same trust boundary. */ +export function deserializeNaruonRehearsalHandoff(serialized: unknown): NaruonRehearsalHandoff { + if ( + typeof serialized !== "string" || + serializedByteLength(serialized) > MAX_NARUON_SERIALIZED_BYTES + ) { + throw new TypeError( + "Invalid naruon rehearsal handoff JSON: serialized payload is invalid or oversized" + ); + } + let value: unknown; + try { + value = JSON.parse(serialized); + } catch { + throw new TypeError("Invalid naruon rehearsal handoff JSON: malformed JSON"); + } + return parseNaruonRehearsalHandoff(value); +} diff --git a/packages/shared-types/test/naruon-calendar.test.ts b/packages/shared-types/test/naruon-calendar.test.ts new file mode 100644 index 000000000..956b86a52 --- /dev/null +++ b/packages/shared-types/test/naruon-calendar.test.ts @@ -0,0 +1,62 @@ +import { + createNaruonRehearsalHandoff, + validateNaruonRehearsalHandoff, + type CreateNaruonRehearsalHandoffInput +} from "../src/naruon"; + +/** Return a valid minimal handoff input with a configurable creation timestamp. */ +function inputWithCreatedAt(createdAt: string): CreateNaruonRehearsalHandoffInput { + return { + createdAt, + source: { + application: "bandscope", + workspaceId: "workspace-calendar-edge", + bandId: "band-calendar-edge", + rehearsalId: "rehearsal-calendar-edge" + }, + normGroup: { + kind: "band", + id: "band-calendar-edge", + label: "Calendar Edge Band" + }, + event: { + title: "Gregorian boundary rehearsal", + startsAt: "2026-08-10T19:00:00+09:00", + endsAt: "2026-08-10T20:00:00+09:00", + timeZone: "Asia/Seoul" + }, + commitment: { + status: "tentative", + rsvpDirection: "attendee" + }, + provenance: { + sourceRecordId: "calendar-edge-record", + confidence: 1, + evidence: [{ field: "createdAt", value: createdAt }] + } + }; +} + +describe("naruon RFC 3339 Gregorian validation", () => { + it("accepts February 29 in year 0000 under the proleptic Gregorian calendar", () => { + expect( + createNaruonRehearsalHandoff( + inputWithCreatedAt("0000-02-29T00:00:00Z") + ).createdAt + ).toBe("0000-02-29T00:00:00Z"); + }); + + it.each([ + "0099-02-29T00:00:00Z", + "1900-02-29T00:00:00Z", + "2100-02-29T00:00:00Z" + ])("rejects February 29 in non-leap year %s", (createdAt) => { + const value = { + artifactKind: "bandscope.naruon.rehearsal-event", + artifactVersion: 1, + ...inputWithCreatedAt(createdAt) + }; + + expect(validateNaruonRehearsalHandoff(value)).toBe("createdAt is invalid"); + }); +}); diff --git a/packages/shared-types/test/naruon-error-redaction.test.ts b/packages/shared-types/test/naruon-error-redaction.test.ts new file mode 100644 index 000000000..3e326c8ae --- /dev/null +++ b/packages/shared-types/test/naruon-error-redaction.test.ts @@ -0,0 +1,64 @@ +import { + createNaruonRehearsalHandoff, + parseNaruonRehearsalHandoff, + validateNaruonRehearsalHandoff, + type CreateNaruonRehearsalHandoffInput +} from "../src/naruon"; + +/** Return one valid handoff for payload-safe validation-error regressions. */ +function validHandoff(): unknown { + const input: CreateNaruonRehearsalHandoffInput = { + createdAt: "2026-08-03T01:23:45Z", + source: { + application: "bandscope", + workspaceId: "workspace-redaction", + bandId: "band-redaction", + rehearsalId: "rehearsal-redaction" + }, + normGroup: { kind: "band", id: "band-redaction", label: "Redaction Band" }, + event: { + title: "Payload-safe rehearsal", + startsAt: "2026-08-10T19:00:00+09:00", + endsAt: "2026-08-10T20:00:00+09:00", + timeZone: "Asia/Seoul" + }, + commitment: { status: "confirmed", rsvpDirection: "organizer" }, + provenance: { + sourceRecordId: "source-redaction", + confidence: 1, + evidence: [{ field: "startsAt", value: "2026-08-10T19:00:00+09:00" }] + } + }; + return createNaruonRehearsalHandoff(input); +} + +describe("naruon validation error payload safety", () => { + it.each([ + ["root", (value: Record, secret: string) => { value[secret] = true; }], + [ + "source", + (value: Record, secret: string) => { + (value.source as Record)[secret] = true; + } + ], + [ + "provenance.evidence[0]", + (value: Record, secret: string) => { + const provenance = value.provenance as { evidence: Record[] }; + provenance.evidence[0]![secret] = true; + } + ] + ])("rejects an unexpected %s field without echoing its attacker-controlled key", (path, mutate) => { + const secret = "private-person-name-and-api-key"; + const value = structuredClone(validHandoff()) as Record; + mutate(value, secret); + + const error = validateNaruonRehearsalHandoff(value); + + expect(error).toBe(`${path} contains an unexpected field`); + expect(error).not.toContain(secret); + expect(() => parseNaruonRehearsalHandoff(value)).toThrow( + `Invalid naruon rehearsal handoff: ${path} contains an unexpected field` + ); + }); +}); diff --git a/packages/shared-types/test/naruon-hardening.test.ts b/packages/shared-types/test/naruon-hardening.test.ts new file mode 100644 index 000000000..9c75c5407 --- /dev/null +++ b/packages/shared-types/test/naruon-hardening.test.ts @@ -0,0 +1,221 @@ +import { + MAX_NARUON_EVIDENCE_RECEIPTS, + MAX_NARUON_SERIALIZED_BYTES, + createNaruonRehearsalHandoff, + deserializeNaruonRehearsalHandoff, + parseNaruonRehearsalHandoff, + serializeNaruonRehearsalHandoff, + validateNaruonRehearsalHandoff, + type CreateNaruonRehearsalHandoffInput +} from "../src/naruon"; + +/** Return one valid handoff input for trust-boundary hardening tests. */ +function validInput(): CreateNaruonRehearsalHandoffInput { + return { + createdAt: "2026-08-03T01:23:45Z", + source: { + application: "bandscope", + workspaceId: "workspace-hardening", + bandId: "band-hardening", + rehearsalId: "rehearsal-hardening" + }, + normGroup: { kind: "band", id: "band-hardening", label: "Hardening Band" }, + event: { + title: "Hardening rehearsal", + startsAt: "2026-08-10T19:00:00+09:00", + endsAt: "2026-08-10T20:00:00+09:00", + timeZone: "Asia/Seoul" + }, + commitment: { status: "confirmed", rsvpDirection: "organizer" }, + provenance: { + sourceRecordId: "source-hardening", + confidence: 1, + evidence: [{ field: "startsAt", value: "2026-08-10T19:00:00+09:00" }] + } + }; +} + +/** Add the fixed artifact discriminator and version to a handoff input. */ +function artifact(input: CreateNaruonRehearsalHandoffInput): unknown { + return { + ...input, + artifactKind: "bandscope.naruon.rehearsal-event", + artifactVersion: 1 + }; +} + +describe("naruon handoff boundary hardening", () => { + it("rejects numeric offsets inconsistent with the required IANA time zone", () => { + const input = validInput(); + input.event.startsAt = "2026-08-10T19:00:00+00:00"; + input.event.endsAt = "2026-08-10T20:00:00+00:00"; + expect(validateNaruonRehearsalHandoff(artifact(input))).toBe( + "event.startsAt offset is inconsistent with event.timeZone" + ); + + input.event.startsAt = "2026-08-10T19:00:00+09:00"; + input.event.endsAt = "2026-08-10T20:00:00+00:00"; + expect(validateNaruonRehearsalHandoff(artifact(input))).toBe( + "event.endsAt offset is inconsistent with event.timeZone" + ); + }); + + it("accepts RFC 9557 unknown-local-offset forms with an explicit IANA zone", () => { + for (const offset of ["Z", "-00:00"]) { + const input = validInput(); + input.event.startsAt = `2026-08-10T10:00:00${offset}`; + input.event.endsAt = `2026-08-10T11:00:00${offset}`; + expect(validateNaruonRehearsalHandoff(artifact(input))).toBeNull(); + } + }); + + it("uses the zone rules at each instant, including daylight-saving changes", () => { + const input = validInput(); + input.event.timeZone = "America/New_York"; + input.event.startsAt = "2026-07-08T09:00:00-04:00"; + input.event.endsAt = "2026-07-08T10:00:00-04:00"; + expect(validateNaruonRehearsalHandoff(artifact(input))).toBeNull(); + + input.event.startsAt = "2026-07-08T09:00:00-05:00"; + input.event.endsAt = "2026-07-08T10:00:00-05:00"; + expect(validateNaruonRehearsalHandoff(artifact(input))).toBe( + "event.startsAt offset is inconsistent with event.timeZone" + ); + }); + + it("snapshots nested accessors once before validation and canonicalization", () => { + const input = validInput(); + let reads = 0; + Object.defineProperty(input.source, "bandId", { + enumerable: true, + get() { + reads += 1; + return reads === 1 ? "band-hardening" : "band-mutated"; + } + }); + + expect(createNaruonRehearsalHandoff(input).source.bandId).toBe("band-hardening"); + expect(reads).toBe(1); + }); + + it("snapshots public validation inputs before reading nested accessors", () => { + const value = artifact(validInput()) as { + source: CreateNaruonRehearsalHandoffInput["source"]; + }; + let reads = 0; + Object.defineProperty(value.source, "bandId", { + enumerable: true, + get() { + reads += 1; + return reads === 1 ? "band-hardening" : "band-mutated"; + } + }); + + expect(validateNaruonRehearsalHandoff(value)).toBeNull(); + expect(reads).toBe(1); + }); + + it("rejects proxy-backed boundary inputs that cannot be snapshotted", () => { + const value = new Proxy(artifact(validInput()) as object, {}); + expect(validateNaruonRehearsalHandoff(value)).toBe( + "root is not structured-cloneable" + ); + expect(() => parseNaruonRehearsalHandoff(value)).toThrow( + "root is not structured-cloneable" + ); + }); + + it("rejects oversized evidence before iterating beyond the contract limit", () => { + const input = validInput(); + input.provenance.evidence = Array.from( + { length: MAX_NARUON_EVIDENCE_RECEIPTS + 1 }, + (_, index) => ({ field: `field-${index}`, value: `value-${index}` }) + ); + expect(validateNaruonRehearsalHandoff(artifact(input))).toBe( + "provenance.evidence is invalid" + ); + }); + + it("rejects handoffs whose canonical UTF-8 serialization exceeds the wire limit", () => { + const input = validInput(); + input.provenance.evidence = Array.from( + { length: MAX_NARUON_EVIDENCE_RECEIPTS }, + (_, index) => ({ field: `field-${index}`, value: "界".repeat(2_048) }) + ); + const value = artifact(input); + + expect(validateNaruonRehearsalHandoff(value)).toBe("serialized handoff is oversized"); + expect(() => parseNaruonRehearsalHandoff(value)).toThrow("serialized handoff is oversized"); + expect(() => serializeNaruonRehearsalHandoff(value)).toThrow( + "serialized handoff is oversized" + ); + }); + + it("bounds untrusted serialized input before JSON parsing", () => { + expect(() => deserializeNaruonRehearsalHandoff(42)).toThrow( + "serialized payload is invalid or oversized" + ); + expect(() => + deserializeNaruonRehearsalHandoff("x".repeat(MAX_NARUON_SERIALIZED_BYTES + 1)) + ).toThrow("serialized payload is invalid or oversized"); + expect(() => deserializeNaruonRehearsalHandoff("😀".repeat(70_000))).toThrow( + "serialized payload is invalid or oversized" + ); + }); + + it("does not echo untrusted JSON fragments in parser errors", () => { + const secret = "private-rehearsal-secret"; + let message = ""; + try { + deserializeNaruonRehearsalHandoff(`{"${secret}":`); + } catch (error) { + message = String(error); + } + + expect(message).toContain("malformed JSON"); + expect(message).not.toContain(secret); + }); +}); + +describe("naruon handoff branch completeness", () => { + it("accepts null-prototype records and rejects hostile prototype traps", () => { + const canonical = createNaruonRehearsalHandoff(validInput()); + const nullPrototypeRoot = Object.assign(Object.create(null), canonical); + expect(validateNaruonRehearsalHandoff(nullPrototypeRoot)).toBeNull(); + + const hostile = new Proxy({}, { + getPrototypeOf() { + throw new Error("prototype unavailable"); + } + }); + expect(validateNaruonRehearsalHandoff(hostile)).toBe( + "root is not structured-cloneable" + ); + }); + + it("covers non-string timestamps, display-invalid zones, and 30-day months", () => { + const nonStringTimestamp = artifact(validInput()) as Record; + nonStringTimestamp.createdAt = 42; + expect(validateNaruonRehearsalHandoff(nonStringTimestamp)).toBe( + "createdAt is invalid" + ); + + const invalidDisplayZone = artifact(validInput()) as { + event: { timeZone: unknown }; + }; + invalidDisplayZone.event.timeZone = ""; + expect(validateNaruonRehearsalHandoff(invalidDisplayZone)).toBe( + "event.timeZone is invalid" + ); + + const validApril = artifact(validInput()) as Record; + validApril.createdAt = "2026-04-30T00:00:00Z"; + expect(validateNaruonRehearsalHandoff(validApril)).toBeNull(); + + const invalidApril = artifact(validInput()) as Record; + invalidApril.createdAt = "2026-04-31T00:00:00Z"; + expect(validateNaruonRehearsalHandoff(invalidApril)).toBe( + "createdAt is invalid" + ); + }); +}); diff --git a/packages/shared-types/test/naruon-schema.test.ts b/packages/shared-types/test/naruon-schema.test.ts new file mode 100644 index 000000000..8907db35c --- /dev/null +++ b/packages/shared-types/test/naruon-schema.test.ts @@ -0,0 +1,126 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { + MAX_NARUON_EVIDENCE_RECEIPTS, + NARUON_REHEARSAL_HANDOFF_KIND, + NARUON_REHEARSAL_HANDOFF_VERSION +} from "../src/naruon"; + +type PatternContract = { pattern: string }; +type ObjectContract = { additionalProperties: boolean }; + +type HandoffSchema = { + $schema: string; + additionalProperties: boolean; + properties: { + artifactKind: { const: string }; + artifactVersion: { const: number }; + source: ObjectContract; + normGroup: ObjectContract; + event: ObjectContract & { + properties: { + timeZone: PatternContract; + }; + }; + commitment: ObjectContract; + provenance: ObjectContract & { + properties: { + evidence: { + maxItems: number; + items: ObjectContract & { + properties: { + field: PatternContract; + }; + }; + }; + }; + }; + }; + $defs: { + opaqueIdentifier: PatternContract; + displayText: PatternContract; + timestamp: PatternContract; + }; +}; + +/** Load the checked-in public JSON Schema from the repository documentation tree. */ +function loadSchema(): HandoffSchema { + const schemaUrl = new URL( + "../../../docs/integrations/naruon-rehearsal-handoff-v1.schema.json", + import.meta.url + ); + return JSON.parse(readFileSync(fileURLToPath(schemaUrl), "utf8")) as HandoffSchema; +} + +/** Compile one schema pattern with the Unicode semantics used by modern validators. */ +function schemaPattern(pattern: PatternContract): RegExp { + return new RegExp(pattern.pattern, "u"); +} + +describe("naruon public JSON Schema", () => { + it("is valid JSON with the same versioned identity as the runtime parser", () => { + const schema = loadSchema(); + const evidence = schema.properties.provenance.properties.evidence; + + expect(schema.$schema).toBe("https://json-schema.org/draft/2020-12/schema"); + expect(schema.properties.artifactKind.const).toBe(NARUON_REHEARSAL_HANDOFF_KIND); + expect(schema.properties.artifactVersion.const).toBe( + NARUON_REHEARSAL_HANDOFF_VERSION + ); + expect(evidence.maxItems).toBe(MAX_NARUON_EVIDENCE_RECEIPTS); + expect(schema.additionalProperties).toBe(false); + expect(schema.properties.source.additionalProperties).toBe(false); + expect(schema.properties.normGroup.additionalProperties).toBe(false); + expect(schema.properties.event.additionalProperties).toBe(false); + expect(schema.properties.commitment.additionalProperties).toBe(false); + expect(schema.properties.provenance.additionalProperties).toBe(false); + expect(evidence.items.additionalProperties).toBe(false); + }); + + it("matches the runtime no-padding contract for public text fields", () => { + const schema = loadSchema(); + const displayText = schemaPattern(schema.$defs.displayText); + const timeZone = schemaPattern(schema.properties.event.properties.timeZone); + const evidenceField = schemaPattern( + schema.properties.provenance.properties.evidence.items.properties.field + ); + + expect(displayText.test("Friday rehearsal")).toBe(true); + expect(displayText.test(" Friday rehearsal")).toBe(false); + expect(displayText.test("Friday rehearsal ")).toBe(false); + expect(displayText.test("\u00a0Friday rehearsal")).toBe(false); + expect(timeZone.test("Asia/Seoul")).toBe(true); + expect(timeZone.test(" Asia/Seoul")).toBe(false); + expect(evidenceField.test("event.startsAt")).toBe(true); + expect(evidenceField.test("event.startsAt ")).toBe(false); + + for (const controlCharacter of ["\u0000", "\t", "\r", "\n"]) { + expect(displayText.test(`Friday${controlCharacter}rehearsal`)).toBe(false); + expect(timeZone.test(`Asia${controlCharacter}Seoul`)).toBe(false); + expect(evidenceField.test(`event${controlCharacter}startsAt`)).toBe(false); + } + }); + + it("keeps identifiers opaque, trimmed, and nonnumeric across Unicode digits", () => { + const opaqueIdentifier = schemaPattern(loadSchema().$defs.opaqueIdentifier); + + expect(opaqueIdentifier.test("band-2026")).toBe(true); + expect(opaqueIdentifier.test("123456")).toBe(false); + expect(opaqueIdentifier.test("١٢٣٤٥٦")).toBe(false); + expect(opaqueIdentifier.test(" band-2026")).toBe(false); + expect(opaqueIdentifier.test("band-2026 ")).toBe(false); + }); + + it("bounds timestamp components before authoritative calendar validation", () => { + const timestamp = schemaPattern(loadSchema().$defs.timestamp); + + expect(timestamp.test("2026-08-10T19:00:00+09:00")).toBe(true); + expect(timestamp.test("2026-08-10T10:00:00Z")).toBe(true); + expect(timestamp.test("2026-08-10T10:00:00-00:00")).toBe(true); + expect(timestamp.test("2026-13-10T10:00:00Z")).toBe(false); + expect(timestamp.test("2026-08-10T24:00:00Z")).toBe(false); + expect(timestamp.test("2026-08-10T10:60:00Z")).toBe(false); + expect(timestamp.test("2026-08-10T10:00:60Z")).toBe(false); + expect(timestamp.test("2026-08-10T10:00:00+24:00")).toBe(false); + }); +}); diff --git a/packages/shared-types/test/naruon.test.ts b/packages/shared-types/test/naruon.test.ts new file mode 100644 index 000000000..fca7f14c2 --- /dev/null +++ b/packages/shared-types/test/naruon.test.ts @@ -0,0 +1,300 @@ +/* eslint-disable @typescript-eslint/no-explicit-any -- boundary tests deliberately construct malformed unknown payloads. */ +import { + MAX_NARUON_EVIDENCE_RECEIPTS, + NARUON_REHEARSAL_HANDOFF_KIND, + NARUON_REHEARSAL_HANDOFF_VERSION, + createNaruonRehearsalHandoff, + deserializeNaruonRehearsalHandoff, + isNaruonRehearsalHandoff, + parseNaruonRehearsalHandoff, + serializeNaruonRehearsalHandoff, + validateNaruonRehearsalHandoff, + type CreateNaruonRehearsalHandoffInput, + type NaruonRehearsalHandoff +} from "../src/naruon"; + +function validInput(): CreateNaruonRehearsalHandoffInput { + return { + createdAt: "2026-08-03T01:23:45.123Z", + source: { + application: "bandscope", + workspaceId: "workspace-local-alpha", + bandId: "band-contextual-wisdom", + rehearsalId: "rehearsal-2026-08-10" + }, + normGroup: { + kind: "band", + id: "band-contextual-wisdom", + label: "Contextual Wisdom Band" + }, + event: { + title: "August rehearsal", + startsAt: "2026-08-10T19:00:00+09:00", + endsAt: "2026-08-10T21:30:00+09:00", + timeZone: "Asia/Seoul", + venue: "Studio A" + }, + commitment: { + status: "confirmed", + rsvpDirection: "organizer" + }, + provenance: { + sourceRecordId: "calendar-record-alpha", + confidence: 0.94, + evidence: [ + { field: "startsAt", value: "2026-08-10T19:00:00+09:00" }, + { field: "venue", value: "Studio A" } + ] + } + }; +} + +function validHandoff(): NaruonRehearsalHandoff { + return createNaruonRehearsalHandoff(validInput()); +} + +function clone(value: unknown): any { + return JSON.parse(JSON.stringify(value)); +} + +describe("naruon rehearsal handoff contract", () => { + it("builds the versioned standalone handoff and preserves integration semantics", () => { + const handoff = validHandoff(); + + expect(handoff.artifactKind).toBe(NARUON_REHEARSAL_HANDOFF_KIND); + expect(handoff.artifactVersion).toBe(NARUON_REHEARSAL_HANDOFF_VERSION); + expect(handoff.source.application).toBe("bandscope"); + expect(handoff.normGroup).toEqual({ + kind: "band", + id: handoff.source.bandId, + label: "Contextual Wisdom Band" + }); + expect(handoff.commitment).toEqual({ + status: "confirmed", + rsvpDirection: "organizer" + }); + expect(validateNaruonRehearsalHandoff(handoff)).toBeNull(); + expect(isNaruonRehearsalHandoff(handoff)).toBe(true); + }); + + it("canonicalizes nested values instead of returning caller-owned objects", () => { + const input = validInput(); + const handoff = createNaruonRehearsalHandoff(input); + + expect(handoff).not.toBe(input); + expect(handoff.source).not.toBe(input.source); + expect(handoff.normGroup).not.toBe(input.normGroup); + expect(handoff.event).not.toBe(input.event); + expect(handoff.commitment).not.toBe(input.commitment); + expect(handoff.provenance).not.toBe(input.provenance); + expect(handoff.provenance.evidence).not.toBe(input.provenance.evidence); + expect(handoff.provenance.evidence[0]).not.toBe(input.provenance.evidence[0]); + + input.source.bandId = "band-mutated"; + input.provenance.evidence[0].value = "mutated"; + expect(handoff.source.bandId).toBe("band-contextual-wisdom"); + expect(handoff.provenance.evidence[0].value).toBe("2026-08-10T19:00:00+09:00"); + }); + + it("omits an absent optional venue from canonical output", () => { + const input = validInput(); + delete input.event.venue; + + const parsed = createNaruonRehearsalHandoff(input); + + expect(parsed.event).toEqual({ + title: "August rehearsal", + startsAt: "2026-08-10T19:00:00+09:00", + endsAt: "2026-08-10T21:30:00+09:00", + timeZone: "Asia/Seoul" + }); + expect("venue" in parsed.event).toBe(false); + }); + + it("produces identical serialization regardless of input key insertion order", () => { + const inputA = validInput(); + const serializedA = serializeNaruonRehearsalHandoff(createNaruonRehearsalHandoff(inputA)); + + const inputB: CreateNaruonRehearsalHandoffInput = { + createdAt: inputA.createdAt, + source: Object.fromEntries(Object.entries(inputA.source).reverse()) as any, + normGroup: Object.fromEntries(Object.entries(inputA.normGroup).reverse()) as any, + event: Object.fromEntries(Object.entries(inputA.event).reverse()) as any, + commitment: Object.fromEntries(Object.entries(inputA.commitment).reverse()) as any, + provenance: { + ...Object.fromEntries( + Object.entries({ + sourceRecordId: inputA.provenance.sourceRecordId, + confidence: inputA.provenance.confidence + }).reverse() + ), + evidence: inputA.provenance.evidence.map((receipt) => + Object.fromEntries(Object.entries(receipt).reverse()) + ) + } as any + }; + const serializedB = serializeNaruonRehearsalHandoff(createNaruonRehearsalHandoff(inputB)); + + expect(serializedA).toBe(serializedB); + }); + + it("serializes deterministically and validates again when deserializing", () => { + const serialized = serializeNaruonRehearsalHandoff(validHandoff()); + + expect(serialized.endsWith("\n")).toBe(true); + expect(deserializeNaruonRehearsalHandoff(serialized)).toEqual(validHandoff()); + expect(() => deserializeNaruonRehearsalHandoff("{not-json")).toThrow( + "Invalid naruon rehearsal handoff JSON" + ); + }); + + it("rejects a non-object root and throws from the parser", () => { + expect(validateNaruonRehearsalHandoff(null)).toBe("root must be an object"); + expect(isNaruonRehearsalHandoff([])).toBe(false); + expect(() => parseNaruonRehearsalHandoff("invalid")).toThrow( + "Invalid naruon rehearsal handoff" + ); + }); + + it.each([ + ["root contains an unexpected field", (value: any) => { value.extra = true; }], + ["artifactKind", (value: any) => { value.artifactKind = "other"; }], + ["artifactVersion", (value: any) => { value.artifactVersion = 2; }], + ["createdAt", (value: any) => { value.createdAt = "2026-08-03"; }], + ["source must", (value: any) => { value.source = null; }], + ["source contains an unexpected field", (value: any) => { value.source.extra = true; }], + ["source.application", (value: any) => { value.source.application = "naruon"; }], + ["source.workspaceId", (value: any) => { value.source.workspaceId = "123"; }], + ["source.bandId", (value: any) => { value.source.bandId = ""; }], + ["source.rehearsalId", (value: any) => { value.source.rehearsalId = "bad\nvalue"; }], + ["normGroup must", (value: any) => { value.normGroup = []; }], + ["normGroup contains an unexpected field", (value: any) => { value.normGroup.extra = true; }], + ["normGroup.kind", (value: any) => { value.normGroup.kind = "team"; }], + ["normGroup.id is", (value: any) => { value.normGroup.id = "44"; }], + ["normGroup.label", (value: any) => { value.normGroup.label = " label "; }], + ["must equal", (value: any) => { value.normGroup.id = "band-other"; }], + ["event must", (value: any) => { value.event = "event"; }], + ["event contains an unexpected field", (value: any) => { value.event.extra = true; }], + ["event.title", (value: any) => { value.event.title = ""; }], + ["event.startsAt", (value: any) => { value.event.startsAt = "not-a-date"; }], + ["event.endsAt is", (value: any) => { value.event.endsAt = "not-a-date"; }], + ["later than", (value: any) => { value.event.endsAt = value.event.startsAt; }], + ["event.timeZone", (value: any) => { value.event.timeZone = "Mars/Olympus"; }], + ["event.venue", (value: any) => { value.event.venue = " "; }], + ["commitment must", (value: any) => { value.commitment = null; }], + ["commitment contains an unexpected field", (value: any) => { value.commitment.extra = true; }], + ["commitment.status", (value: any) => { value.commitment.status = "maybe"; }], + ["commitment.rsvpDirection", (value: any) => { value.commitment.rsvpDirection = "observer"; }], + ["provenance must", (value: any) => { value.provenance = null; }], + ["provenance contains an unexpected field", (value: any) => { value.provenance.extra = true; }], + ["provenance.sourceRecordId", (value: any) => { value.provenance.sourceRecordId = "7"; }], + ["provenance.confidence", (value: any) => { value.provenance.confidence = Number.NaN; }], + ["provenance.evidence", (value: any) => { value.provenance.evidence = "receipt"; }], + ["evidence[0] must", (value: any) => { value.provenance.evidence[0] = null; }], + ["evidence[0] contains an unexpected field", (value: any) => { value.provenance.evidence[0].extra = true; }], + ["evidence[0].field", (value: any) => { value.provenance.evidence[0].field = ""; }], + ["evidence[0].value", (value: any) => { value.provenance.evidence[0].value = "bad\nvalue"; }] + ])("fails closed for %s", (expected, mutate) => { + const value = clone(validHandoff()); + mutate(value); + + expect(validateNaruonRehearsalHandoff(value)).toContain(expected); + expect(isNaruonRehearsalHandoff(value)).toBe(false); + expect(() => parseNaruonRehearsalHandoff(value)).toThrow( + "Invalid naruon rehearsal handoff" + ); + }); + + it.each([ + "2026-00-01T00:00:00Z", + "2026-13-01T00:00:00Z", + "2026-02-30T00:00:00Z", + "2026-01-00T00:00:00Z", + "2026-01-01T24:00:00Z", + "2026-01-01T00:60:00Z", + "2026-01-01T00:00:60Z", + "2026-01-01T00:00:00+24:00", + "2026-01-01T00:00:00+01:60", + "2026-01-01T00:00:00.1234567890Z" + ])("rejects calendrically invalid RFC 3339 timestamp %s", (createdAt) => { + const value = clone(validHandoff()); + value.createdAt = createdAt; + + expect(validateNaruonRehearsalHandoff(value)).toBe("createdAt is invalid"); + }); + + it.each([null, -0.01, 1.01, Number.POSITIVE_INFINITY])( + "rejects invalid calibrated confidence %s", + (confidence) => { + const value = clone(validHandoff()); + value.provenance.confidence = confidence; + + expect(validateNaruonRehearsalHandoff(value)).toBe( + "provenance.confidence is invalid" + ); + } + ); + + it("accepts confidence boundaries and every commitment-axis combination", () => { + for (const confidence of [0, 1]) { + for (const status of ["confirmed", "tentative", "desired"] as const) { + for (const rsvpDirection of ["organizer", "attendee"] as const) { + const value = clone(validHandoff()); + value.provenance.confidence = confidence; + value.commitment.status = status; + value.commitment.rsvpDirection = rsvpDirection; + expect(validateNaruonRehearsalHandoff(value)).toBeNull(); + } + } + } + }); + + it("rejects empty, oversized, and sparse provenance receipt collections", () => { + const empty = clone(validHandoff()); + empty.provenance.evidence = []; + expect(validateNaruonRehearsalHandoff(empty)).toBe("provenance.evidence is invalid"); + + const oversized = clone(validHandoff()); + oversized.provenance.evidence = Array.from( + { length: MAX_NARUON_EVIDENCE_RECEIPTS + 1 }, + (_, index) => ({ field: `field-${index}`, value: `value-${index}` }) + ); + expect(validateNaruonRehearsalHandoff(oversized)).toBe( + "provenance.evidence is invalid" + ); + + const sparse = clone(validHandoff()); + sparse.provenance.evidence = new Array(1); + expect(validateNaruonRehearsalHandoff(sparse)).toBe("provenance.evidence is invalid"); + }); + + it("rejects oversized fields and allows the UTC time zone", () => { + const oversizedIdentifier = clone(validHandoff()); + oversizedIdentifier.source.workspaceId = `id-${"x".repeat(256)}`; + expect(validateNaruonRehearsalHandoff(oversizedIdentifier)).toBe( + "source.workspaceId is invalid" + ); + + const oversizedText = clone(validHandoff()); + oversizedText.event.title = "x".repeat(2_049); + expect(validateNaruonRehearsalHandoff(oversizedText)).toBe("event.title is invalid"); + + const utc = clone(validHandoff()); + utc.event.timeZone = "UTC"; + utc.event.startsAt = "2026-08-10T10:00:00Z"; + utc.event.endsAt = "2026-08-10T12:30:00Z"; + expect(validateNaruonRehearsalHandoff(utc)).toBeNull(); + }); + + it("snapshots hostile array length metadata and rejects the forged collection", () => { + const value = clone(validHandoff()); + value.provenance.evidence = new Proxy([{ field: "title", value: "Rehearsal" }], { + get(target, property, receiver) { + if (property === "length") return Number.MAX_SAFE_INTEGER + 1; + return Reflect.get(target, property, receiver); + } + }); + + expect(validateNaruonRehearsalHandoff(value)).toBe("root is not structured-cloneable"); + }); +}); diff --git a/packages/shared-types/vitest.config.ts b/packages/shared-types/vitest.config.ts index 14e004545..c4a1edf6b 100644 --- a/packages/shared-types/vitest.config.ts +++ b/packages/shared-types/vitest.config.ts @@ -5,12 +5,18 @@ export default defineConfig({ globals: true, coverage: { provider: "v8", - include: ["src/index.ts"], + include: ["src/index.ts", "src/naruon.ts"], thresholds: { lines: 90, functions: 90, branches: 90, - statements: 90 + statements: 90, + "src/naruon.ts": { + lines: 100, + functions: 100, + branches: 100, + statements: 100 + } } } }