@plasius/schema
v1.4.7
Published
Entity schema definition & validation helpers for Plasius ecosystem
Maintainers
Readme
@plasius/schema
Entity definition & validation helpers for the Plasius ecosystem.
Apache-2.0. ESM + CJS builds. TypeScript types included.
Installation
npm install @plasius/schemaPersonal names and display names
Use validateName for first, middle, and last names. It accepts Unicode
letters and combining marks together with common name punctuation, but it does
not accept digits. Use validateDisplayName for public display names, where
Unicode decimal digits are also valid:
import { validateDisplayName, validateName } from "@plasius/schema";
validateDisplayName("Player 2"); // true
validateName("Player 2"); // falseBoth validators reject empty values, control characters, unsupported symbols, and values longer than 256 characters.
Packaging policy (template baseline)
@plasius/schema is the baseline template for new @plasius/* packages.
Dual-module packages should prefer .cjs outputs for CommonJS. If a package
uses dist-cjs/*.js with type: module, it must generate
dist-cjs/package.json with { "type": "commonjs" } and validate this in
pack:check before publish.
Private-artifact policy
Signed contributor agreements and contributor acceptance records belong only in the approved access-controlled system outside source control. The zero-dependency repository gate inspects path metadata without opening, hashing, copying, or logging suspected private artifacts:
npm run privacy:check
npm run test:privacy
npm run build
npm run pack:checkprivacy:check checks both filesystem metadata and the proposed Git index. It
rejects CSV files, contributor/CLA registry variants, contributor acceptance,
signature, and submission record categories, signed CLA/contributor agreement
storage, and paths that combine privacy and registry markers. Explicit public
process, template, schema, validator, and policy files remain allowed only as
terminal path components; matching private-record categories used as
directories fail closed regardless of their descendants.
Unicode compatibility normalization occurs before separator folding so
hierarchical, case-variant, and compatibility-separated protected paths cannot
evade the same path-only classification. An unstaged deletion remains a
failure until the deletion is staged. Invalid Git worktree metadata, symbolic
links, and non-regular entries fail closed, and failures report only rule IDs
and counts.
pack:check requires the exact dist, THIRD_PARTY_NOTICES.md, and unicode
package.json.files entries, then retains prefix-stripped raw member identities
and cardinality while comparing the canonical npm pack --dry-run manifest
with the package's exact public allowlist. Duplicate members, raw aliases, and
many-to-one normalization collisions fail without logging member values. The
same zero-dependency inventory policy runs over the digest-verified sealed
tarball immediately before publication. The isolated npm cache is removed
whether the check passes or fails. CI runs the repository gate before
dependency installation; release preparation and publication repeat the gate.
These path controls are defense in depth and do not replace secret scanning,
access controls, or incident response.
Player-system event contracts
The package exports versioned contracts for a blob-backed, privacy-safe player event pipeline:
import {
PlayerSystemCuratedSnapshotSchema,
PlayerSystemNormalizedEventBatchSchema,
PlayerSystemObservedEventBatchSchema,
} from "@plasius/schema";The contracts cover raw observed-event ingress, normalized batches with
deterministic keys, and curated per-player Event Log/Achievement snapshots.
Only the stable playerId is treated as a personal identifier. It is retained
for storage partitioning and pseudonymized by sanitizeForLog; unknown fields
and hidden server truth are excluded by schema serialization.
Privacy-safe feedback contracts
The package exports closed, versioned contracts for feedback intake and reporting:
import {
FeedbackBugHealthMetricsProjectionSchema,
FeedbackBugPacketSchema,
FeedbackCommittedAcceptanceEvidenceSchema,
FeedbackPublicSummarySchema,
FeedbackRichTextAstSchema,
FeedbackReviewPacketSchema,
FeedbackTransientAnalysisRequestSchema,
validateFeedbackTransientAnalysisRequest,
} from "@plasius/schema";
import type {
FeedbackBugTransientAnalysisRequest,
FeedbackReviewTransientAnalysisRequest,
} from "@plasius/schema";Renderer and browser packages that need only the game-diagnostics vocabulary
should import @plasius/schema/feedback-diagnostics-vocabulary. This
lightweight entrypoint exports the canonical surface/provenance pairs, buckets,
feature/counter/error allowlists, bounds, and discriminated type without
loading the schema builder. Server validation and analysis tools should import
@plasius/schema/feedback-diagnostics, which adds the canonical runtime schema.
Both entrypoints derive from the same source definitions and preserve
structural parity in ESM and CommonJS builds, so consumers cannot maintain a
second diagnostics dialect. Use validateFeedbackGameDiagnostics() when a
consumer needs a validation result whose value retains the correlated
FeedbackGameDiagnostics TypeScript type.
Free-form feedback exists only in FeedbackRichTextAstSchema, which is
intended for a transient no-retention scanner. Its text nodes are marked
high-PII, clear-on-storage, and omit-from-logs. The interoperable document is
rooted at
{ type: "doc", version: "1.0.0", schemaVersion: "1", children }; it permits
only paragraph and bullet-list-item blocks with bounded indentation and text
nodes with bold, italic, and underline marks. Narrative must already be NFKC
normalised against the exported unicode-15.1.0-nfkc-v1 profile before
validation. Assignment is checked against the original code units before host
NFKC is invoked. Its 4,000-character ceiling counts Unicode code points and
inter-block newlines consistently with the private scanner, with a separate
8,000 UTF-16-unit safety ceiling. HTML, Markdown links, protocol-relative
links, and http, https, ftp, mailto, javascript, data, blob, and
file schemes are rejected.
FeedbackTransientAnalysisRequestSchema is closed and discriminated by
purpose. A bug analysis request must carry a closed surfaceId; the service
must verify it against the caller's capability-projected catalog before
handing ciphertext to the private scanner. A review analysis request must omit
surfaceId. The exported bug/review request types preserve the same
discriminator for TypeScript consumers;
validateFeedbackTransientAnalysisRequest() preserves that union on
successful runtime validation.
Final bug and review JSON bodies deliberately contain no submissionId.
Consuming HTTP APIs require Idempotency-Key as the sole final-submission
correlation mechanism and keep it in the isolated abuse/idempotency control
plane. It must never be copied into a draft, accepted packet, report, log, or
public/Admin/MCP projection.
The complete Unicode 15.1 unassigned corpus is shipped independently of the host runtime:
import {
FEEDBACK_UNICODE_PROFILE_ENDPOINT_SHA256,
FEEDBACK_UNICODE_PROFILE_ID,
containsFeedbackUnicodeProfileUnsupportedText,
isFeedbackUnicodeProfileUnassigned,
} from "@plasius/schema/feedback-unicode-profile";containsFeedbackUnicodeProfileUnsupportedText rejects every code point
unassigned in Unicode 15.1 and every lone UTF-16 surrogate. The code-point
helper decodes canonical, delta ULEB128/base64url once and uses binary search
over 707 half-open ranges. Non-JavaScript scanners can consume the same
language-neutral corpus from
@plasius/schema/unicode/feedback-unicode-15.1.0-unassigned.json and verify
its published endpoint digest. The corpus is generated from the exact
build-time dependency @unicode/[email protected]; run
npm run unicode:check to prove the checked-in artifact is current. See
ADR-0007 and
third-party notices.
The three typed feedback subpaths publish both modern exports.types entries
and exact typesVersions fallbacks. This keeps their declarations resolvable
for consumers that still compile CommonJS with classic TypeScript
moduleResolution: "node", without opening a wildcard route for unknown
subpaths.
Validated reusable fragments retain exact type and version metadata when
embedded in parent contracts. This makes the output of the encrypted-envelope,
derived-analysis, and game-diagnostics schemas directly composable into their
request or packet schemas without a metadata-stripping step.
Accepted packet, diagnostics, report, checkpoint, and public-summary schemas
cannot express narrative, reporter identity or pseudonyms, network/client
metadata, arbitrary URLs, pixels, quotes, summaries, embeddings, content
hashes, or model traces. Game surfaces, provenance contracts, features,
counters, and error codes are closed enums rather than attacker-controlled
safe-looking strings. The public summary omits counts, averages, and trends
entirely when the ten-review privacy threshold is not met and always carries
13 canonical UTC week slots, using suppressed gaps where a week is below the
same threshold. A trend is emitted only with a preceding 90-day comparison
that independently meets the ten-review threshold; the schema derives its
delta and enforces up/down only at a change of at least 0.1 stars.
Hourly reports bind targets to the closed surface registry and include renderer/backend/viewport/frame-rate/frame-time plus closed diagnostic feature/counter/error distributions. Release IDs and build IDs are server-registry values: intake clients never submit them, and consumers must resolve them from trusted deployment metadata before packet construction. Advisory trigger counts and ordered recommendation IDs are derived exactly from the report distributions and processor lag rather than accepted as free-standing claims. Feedback context caps remaining bug cooldown at 24 hours and review suppression at 30 days; acceptance receipts distinguish bugs from reviews and accept only the five-step bug ladder or exact 30-day review period.
FeedbackCommittedAcceptanceEvidenceSchema is a separate, durable proof that
the control-plane commit for one immutable packet completed. It contains only
the canonical packet UUID, bug/review kind, and the packet's canonical
server-owned UTC acceptance time. Canonical timestamp validation rejects
impossible calendar or clock values as ordinary closed validation failures; it
does not expose the rejected value or a native date exception. It cannot
express a control-state key, reservation, idempotency value, reporter
pseudonym, request metadata, Blob locator, content hash, narrative, ciphertext,
or pixels, and its PII audit is empty. Only a trusted delivery worker may
construct this evidence after an atomic control-plane commit; it is not an
intake request. Report processors must select packets through this evidence
rather than by enumerating packet storage, preventing an immutable Blob written
before a failed commit from entering aggregates. See
ADR-0011.
FeedbackBugHealthMetricsProjectionSchema is the separate trusted input for
facts that accepted packets cannot supply: application rejection counts,
privacy-safe traffic denominators, and allowlisted abuse-control block bands.
It covers one exact UTC hour, is finalized no earlier than the window end, and
contains no raw edge event, request, reporter, client, route, narrative, or
pixel data. Abuse bands are unique, non-zero, and ordered by the frozen
FEEDBACK_ABUSE_BLOCK_BANDS vocabulary. A producer must derive the
projectionId deterministically from the hour and consumers must verify that
binding before materialization; the schema deliberately performs no
environment-dependent hashing. See
ADR-0012.
Materialization manifests may identify a revision as a metrics-source
correction when an independently finalized identifier-free metric changed but
no packet arrived late. That reason requires lateArrivalCount: 0; ordinary
late-arrival corrections continue to require a positive late-arrival count.
All feedback contracts opt in to recursive unknown-field rejection, including
direct references and arrays of references. Unshaped references admit only
type and id; shaped references admit only those keys plus their declared
fields. Closed feedback vocabularies and definition objects are runtime frozen
before schemas capture them. Before recursive cloning, strict validation also
performs an iterative whole-value node/depth budget and inspects own data
property descriptors. It rejects enumerable accessors, symbol keys, exotic
prototypes, sparse or oversized arrays, and malformed proxy traps without
evaluating accessor values or reflecting thrown messages, so malformed
containers hidden under scalar fields cannot exhaust the call stack or expand
into an attacker-selected logical array length. The
general schema default remains backwards-compatible stripping:
const ClosedPacket = createSchema(fields, "closed-packet", {
version: "1.0.0",
piiEnforcement: "strict",
unknownFields: "reject",
identity: "exact",
});Schema-level validators receive an optional, frozen
SchemaValidationContext with only wasProvided(fieldName). This lets a
closed discriminator distinguish omission from an explicit null without
giving validation hooks access to raw field values.
Unknown-field errors name only the schema-owned container and never echo an untrusted key or value. Exact identity rejects a mismatched contract type or version. The schemas constrain data shape; consumers must still perform the documented private PII scan, discard narrative immediately, and validate again before every storage write. See the feedback contract design and ADR-0006.
Demo
npm run build
node demo/example.mjsSee demo/README.md for the local sanity-check scaffold.
Node.js Version
This project uses Node.js 24 by default. The version is pinned in the .nvmrc file.
If you use nvm, simply run:
nvm useThis ensures your local development environment matches the version used in CI/CD.
Usage Example
Imports
import {
// core
createSchema,
field,
getSchemaForType,
getAllSchemas,
Infer
} from "@plasius/schema";1) Define fields with the field() builder
Below uses the fluent builder exported via
field/field.builder.
const UserFields = {
id: field.uuid().required().description("Unique user id"),
email: field.email().required(),
name: field.generalText().optional(),
age: field.number().min(0).optional(),
roles: field.array(field.string().enum(["admin", "user"]))
.default(["user"])
.description("RBAC roles"),
createdAt: field.dateTimeISO().default(() => new Date().toISOString()),
};Common methods (non‑exhaustive): .required(), .optional(), .default(v|fn), .description(text), and type‑specific helpers like .email(), .uuid(), .min(), .max(), .enum([...]).
Validation standards notes:
languageCode()checks a documented RFC 5646/BCP 47 subset case-insensitively, including grandfathered and private-use tags; two-letter primaries are checked against the package's ISO 639-1 registry. Registered 4-8 letter primaries remain outside this dependency-free subset.uuid()accepts the RFC 9562 versions 1-8, Nil, and Max text forms. Legacy RFC 4122-named module paths remain compatibility aliases.email()is a pragmatic dot-domain mailbox subset, not a claim of complete RFC 5322 grammar support.
Runtime rollout inherits governance.rfc-compliance-remediation.enabled;
disabled consumers may retain the prior validators only during a documented
migration window.
Defaults are applied during validation when inputs are missing/undefined.
Fields are required by default; call .optional() (or provide .default()) to allow omission.
Use .internal() or .exposure("internal") on fields that the server may validate/store but must not include in client-facing payloads.
2) Create a versioned schema (enforces type + version)
export const UserSchema = createSchema(UserFields, "user", {
version: "1.0.0",
piiEnforcement: "strict",
});
// Strongly-typed entity from a schema definition
export type User = Infer<typeof UserSchema>;Schemas are discoverable at runtime if you register them during module init:
// later in app code
const s = getSchemaForType("user"); // returns UserSchema
const all = getAllSchemas(); // Map<string, Map<string, Schema>> or similar3) Validate data against the schema
const raw = {
type: "user",
version: "1.0.0",
id: crypto.randomUUID(),
email: "[email protected]",
};
const result = UserSchema.validate(raw);
if (result.valid && result.errors.length == 0) {
// result.value is typed as User
const user: User = result.value;
} else {
// result.errors: ValidationError[] (path/code/message)
console.error(result.errors);
}If your validation layer also exposes a throwing variant (e.g.
validateOrThrow(UserSchema, raw)), you can use that in places where exceptions are preferred.
- Validation highlights:
- Array item validators (e.g.
.pattern(),.min(),.max()) run per element for primitive arrays. - Arrays of refs validate nested ref shapes (defaults, required fields, and validators) when provided.
- Ref fields enforce their declared
refTypeduring validation, catching mismatches early. - PII helpers recurse through nested objects/arrays/refs so encrypted/hashed/cleared fields are handled throughout the structure (including array items).
- ISO lists stay current (
PScountry code,SLEcurrency code) and the validation package exportsvalidateLanguagefor BCP 47 tags. - Numeric enums are enforced like string enums instead of accepting out-of-range values.
- Immutable flags are honored on nested object/array/ref children when an existing entity is provided.
- PII strict/warn enforcement runs on nested fields, preventing empty high-PII subfields from slipping through.
- Validation deep-clones inputs before applying defaults, so caller-provided objects/arrays aren’t mutated and non-JSON-safe values (e.g.,
Date) are preserved.
- Array item validators (e.g.
3b) Serialize only client-safe fields
Schema serialization is separate from validation. serialize() keeps only schema-known fields and drops fields marked .internal() unless explicitly requested.
const PersistedUserSchema = createSchema(
{
id: field.string(),
partitionKey: field.string().internal(),
email: field.email(),
audit: field.object({
createdBy: field.string().internal(),
createdAt: field.dateTimeISO(),
}),
},
"persisted-user",
{ version: "1.0.0", piiEnforcement: "strict" }
);
const publicPayload = PersistedUserSchema.serialize({
type: "persisted-user",
version: "1.0.0",
id: "123",
partitionKey: "tenant-a",
email: "[email protected]",
audit: {
createdBy: "admin-1",
createdAt: "2026-03-09T00:00:00.000Z",
},
_rid: "cosmos-only",
});
// => {
// type: "persisted-user",
// version: "1.0.0",
// id: "123",
// email: "[email protected]",
// audit: { createdAt: "2026-03-09T00:00:00.000Z" }
// }4) Version enforcement in action
If either type or version doesn’t match the schema, validation fails.
const wrong = { type: "User", version: "2.0.0", id: "123", email: "x@x" };
const bad = UserSchema.validate(wrong);
// bad.valid === false; errors will include mismatches for type/version5) Evolving your schema
Keep new versions side‑by‑side and migrate at edges:
export const UserV2 = createSchema(
{
...UserFields,
displayName: field.string().min(1).max(100).optional(),
},
"user",
{ version: "2.0.0", piiEnforcement: "strict" }
);Write a small migration function in your app to transform
User (1.0.0)→User (2.0.0)where needed.
6) Field-level upgrades
The schema supports a new .upgrade() method on fields to define field-level upgrade logic. This is useful when tightening restrictions on a field, such as reducing maximum length, strengthening format constraints, or normalizing values, without changing the field’s overall shape.
For example, suppose a displayName field previously allowed strings up to 60 characters, but you want to reduce the max length to 55 characters and normalize whitespace by trimming and collapsing spaces. You can define an upgrader function that attempts to fix old values to meet the new constraints:
const UserV3Fields = {
...UserFields,
displayName: field.string().max(55).optional()
.upgrade((oldValue) => {
if (typeof oldValue !== "string") {
return { ok: false, error: "Expected string" };
}
// Normalize whitespace: trim and collapse multiple spaces
const normalized = oldValue.trim().replace(/\s+/g, " ");
if (normalized.length > 55) {
return { ok: false, error: "Display name too long after normalization" };
}
return { ok: true, value: normalized };
}),
};
export const UserV3 = createSchema(UserV3Fields, "user", {
version: "3.0.0",
piiEnforcement: "strict",
});Other typical upgrade strategies include:
- Clamping numeric values to new min/max bounds
- Remapping enum values to new sets or keys
- Normalizing whitespace or case in strings
- Converting deprecated flag values to new formats
During validation, if the entity version is less than the schema version and the field's value fails validation, the upgrader function will be invoked to attempt to transform the old value into a valid new value. If the upgrade succeeds and the transformed value passes validation, the upgraded value is used. If the upgrade fails or the transformed value still does not validate, validation errors will be returned.
Note: Field-level upgrades only run when the schema version is greater than the entity version and the field validation initially fails. This provides a convenient way to handle incremental field changes without requiring full schema migrations.
You can still write schema-level migration functions for larger or more complex changes that affect multiple fields or require more extensive transformation logic. Field-level upgrades complement these by handling simpler, localized upgrades directly within the schema definition.
Contributing
We welcome contributions! Please see CONTRIBUTING.md for guidelines.
License
Licensed under the Apache-2.0 License.
Release integrity
CI keeps the administrative contributor registry outside Git and npm package
artifacts using exact, case-normalised path checks. Pull requests run on
GitHub-hosted runners after same-repository admission; protected main CI uses
the workflow-restricted self-hosted group. Release preparation and npm
publication use GitHub-hosted runners with Node.js 24.18.0 LTS.
Dispatch cd.yml from protected main with phase: prepare. Release metadata
lands through a unique pull request. After successful push-triggered CI for the
exact merge SHA, the workflow dispatches a separate publish run from that
same SHA. Publication fails closed if main, CI evidence, the version, tag,
prerelease identity, artifact digests, or npm registry integrity differ.
The read-only validation job installs dependencies, validates the package,
builds an SBOM, and seals an immutable tarball. The production job runs no
package lifecycle or dependency code; it verifies the exact artifact hand-off
and rechecks the collision-free exact raw-member inventory before publishing
that tarball through npm OIDC with provenance. No npm write token or fallback
is configured. The trusted publisher must be bound to Plasius-LTD/schema,
cd.yml, production, and npm publish. Rollback is to disable cd.yml;
never restore token-based publication.
