@plasius/schema 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/.eslintrc.cjs +7 -0
  2. package/.github/workflows/cd.yml +54 -0
  3. package/.github/workflows/ci.yml +16 -0
  4. package/.vscode/launch.json +15 -0
  5. package/CODE_OF_CONDUCT.md +79 -0
  6. package/CONTRIBUTORS.md +27 -0
  7. package/LICENSE +203 -0
  8. package/README.md +45 -0
  9. package/SECURITY.md +17 -0
  10. package/legal/CLA-REGISTRY.csv +2 -0
  11. package/legal/CLA.md +22 -0
  12. package/legal/CORPORATE_CLA.md +55 -0
  13. package/legal/INDIVIDUAL_CLA.md +91 -0
  14. package/package.json +48 -0
  15. package/src/components.ts +39 -0
  16. package/src/field.builder.ts +119 -0
  17. package/src/field.ts +14 -0
  18. package/src/index.ts +7 -0
  19. package/src/infer.ts +34 -0
  20. package/src/pii.ts +165 -0
  21. package/src/schema.ts +757 -0
  22. package/src/types.ts +156 -0
  23. package/src/validation/countryCode.ISO3166.ts +256 -0
  24. package/src/validation/currencyCode.ISO4217.ts +191 -0
  25. package/src/validation/dateTime.ISO8601.ts +9 -0
  26. package/src/validation/email.RFC5322.ts +9 -0
  27. package/src/validation/generalText.OWASP.ts +39 -0
  28. package/src/validation/index.ts +13 -0
  29. package/src/validation/name.OWASP.ts +25 -0
  30. package/src/validation/percentage.ISO80000-1.ts +8 -0
  31. package/src/validation/phone.E.164.ts +9 -0
  32. package/src/validation/richtext.OWASP.ts +34 -0
  33. package/src/validation/url.WHATWG.ts +16 -0
  34. package/src/validation/user.MS-GOOGLE-APPLE.ts +31 -0
  35. package/src/validation/uuid.RFC4122.ts +10 -0
  36. package/src/validation/version.SEMVER2.0.0.ts +8 -0
  37. package/tests/pii.test.ts +139 -0
  38. package/tests/schema.test.ts +501 -0
  39. package/tests/test-utils.ts +97 -0
  40. package/tests/validate.test.ts +97 -0
  41. package/tests/validation.test.ts +98 -0
  42. package/tsconfig.build.json +19 -0
  43. package/tsconfig.json +7 -0
  44. package/tsup.config.ts +10 -0
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@plasius/schema",
3
+ "version": "1.0.0",
4
+ "description": "Entity schema definition & validation helpers for Plasius ecosystem",
5
+ "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "require": "./dist/index.cjs"
14
+ }
15
+ },
16
+ "scripts": {
17
+ "build": "tsup",
18
+ "test": "vitest run",
19
+ "test:watch": "vitest",
20
+ "lint": "eslint .",
21
+ "prepare": "npm run build",
22
+ "clean": "rimraf dist"
23
+ },
24
+ "keywords": [],
25
+ "author": "Plasius LTD <development@plasius.co.uk>",
26
+ "license": "Apache-2.0",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "https://github.com/Plasius-LTD/schema.git"
30
+ },
31
+ "bugs": {
32
+ "url": "https://github.com/Plasius-LTD/schema/issues"
33
+ },
34
+ "homepage": "https://github.com/Plasius-LTD/schema#readme",
35
+ "devDependencies": {
36
+ "@types/node": "^24.3.1",
37
+ "@typescript-eslint/eslint-plugin": "^8.43.0",
38
+ "@typescript-eslint/parser": "^8.43.0",
39
+ "eslint": "^9.35.0",
40
+ "tsup": "^8.5.0",
41
+ "tsx": "^4.20.5",
42
+ "typescript": "^5.9.2",
43
+ "vitest": "^3.2.4"
44
+ },
45
+ "publishConfig": {
46
+ "access": "public"
47
+ }
48
+ }
@@ -0,0 +1,39 @@
1
+ import { Infer } from "./infer.js";
2
+ import { createSchema } from "./schema.js";
3
+ import { Schema, SchemaShape } from "./types.js";
4
+ // This module provides a registry for component schemas, allowing components to be registered and retrieved by type.
5
+ const componentSchemaRegistry = new Map<string, Schema<SchemaShape>>();
6
+
7
+ export function createComponentSchema<T extends SchemaShape>(
8
+ shape: SchemaShape,
9
+ name: string,
10
+ version: string,
11
+ tableName: string = "",
12
+ schemaValidator: (entity: Infer<T>) => boolean = () => true
13
+ ): Schema<SchemaShape> {
14
+ const schema = createSchema<T>(shape as T, name, {
15
+ version: version,
16
+ piiEnforcement: "strict",
17
+ table: tableName,
18
+ schemaValidator,
19
+ });
20
+ registerComponentSchema(name, schema as Schema<SchemaShape>);
21
+ return schema as Schema<SchemaShape>;
22
+ }
23
+
24
+ export function registerComponentSchema(
25
+ type: string,
26
+ schema: Schema<SchemaShape>
27
+ ) {
28
+ componentSchemaRegistry.set(type, schema);
29
+ }
30
+
31
+ export function getComponentSchema(
32
+ type: string
33
+ ): Schema<SchemaShape> | undefined {
34
+ return componentSchemaRegistry.get(type);
35
+ }
36
+
37
+ export function getAllComponentSchemas(): [string, Schema<SchemaShape>][] {
38
+ return Array.from(componentSchemaRegistry.entries());
39
+ }
@@ -0,0 +1,119 @@
1
+ type FieldType = "string" | "number" | "boolean" | "object" | "array" | "ref";
2
+
3
+ import { type PII } from "./pii.js";
4
+ import { getSchemaForType } from "./schema.js";
5
+
6
+ export class FieldBuilder<TExternal = unknown, TInternal = TExternal> {
7
+ _type!: TExternal;
8
+ _storageType!: TInternal;
9
+ isSystem = false;
10
+ isImmutable = false;
11
+ isRequired = true;
12
+ _validator?: (value: any) => boolean;
13
+ _description: string = "";
14
+ _version: string = "1.0";
15
+ _shape?: Record<string, FieldBuilder<any>>;
16
+ itemType?: FieldBuilder<any>;
17
+ refType?: string;
18
+ _pii: PII = {
19
+ classification: "none",
20
+ action: "none",
21
+ logHandling: "plain",
22
+ purpose: "an ordinary value",
23
+ };
24
+ enumValues?: readonly TInternal[];
25
+
26
+ constructor(
27
+ public type: FieldType,
28
+ options: {
29
+ shape?: Record<string, FieldBuilder<any>>;
30
+ itemType?: FieldBuilder<any>;
31
+ refType?: string;
32
+ } = {}
33
+ ) {
34
+ this._shape = options.shape;
35
+ this.itemType = options.itemType;
36
+ this.refType = options.refType;
37
+ }
38
+
39
+ immutable(): FieldBuilder<TExternal, TInternal> {
40
+ this.isImmutable = true;
41
+ return this;
42
+ }
43
+
44
+ system(): FieldBuilder<TExternal, TInternal> {
45
+ this.isSystem = true;
46
+ return this;
47
+ }
48
+
49
+ required(): FieldBuilder<TExternal, TInternal> {
50
+ this.isRequired = true;
51
+ return this;
52
+ }
53
+
54
+ optional(): FieldBuilder<TExternal, TInternal> {
55
+ this.isRequired = false;
56
+ return this;
57
+ }
58
+
59
+ validator(
60
+ fn: (value: TInternal) => boolean
61
+ ): FieldBuilder<TExternal, TInternal> {
62
+ this._validator = fn;
63
+ return this;
64
+ }
65
+
66
+ description(desc: string): FieldBuilder<TExternal, TInternal> {
67
+ this._description = desc;
68
+ return this;
69
+ }
70
+
71
+ version(ver: string): FieldBuilder<TExternal, TInternal> {
72
+ this._version = ver;
73
+ return this;
74
+ }
75
+
76
+ /// PID informs the schema PII handling of the manner in
77
+ /// which to handle data relating to this field.
78
+ PID(pii: PII): FieldBuilder<TExternal, TInternal> {
79
+ this._pii = pii;
80
+ return this;
81
+ }
82
+
83
+ enum<const U extends readonly TInternal[]>(
84
+ values: U
85
+ ): FieldBuilder<U[number]> {
86
+ if (
87
+ this.type !== "string" &&
88
+ this.type !== "number" &&
89
+ !(
90
+ this.type === "array" &&
91
+ (this.itemType?.type === "string" || this.itemType?.type === "number")
92
+ )
93
+ ) {
94
+ throw new Error(
95
+ "Enums are only supported on string or number fields or arrays of strings or numbers."
96
+ );
97
+ }
98
+ this.enumValues = values;
99
+ return this as any;
100
+ }
101
+
102
+ as<U>(): FieldBuilder<U, TInternal> {
103
+ const clone = new FieldBuilder<U, TInternal>(this.type, {
104
+ shape: this._shape,
105
+ itemType: this.itemType,
106
+ });
107
+ clone.enumValues = this.enumValues;
108
+ clone.isImmutable = this.isImmutable;
109
+ clone.isSystem = this.isSystem;
110
+ clone.isRequired = this.isRequired;
111
+ clone._description = this._description;
112
+ clone._version = this._version;
113
+ clone._pii = this._pii;
114
+ clone._validator = this._validator as any;
115
+ return clone;
116
+ }
117
+ }
118
+
119
+ export default FieldBuilder;
package/src/field.ts ADDED
@@ -0,0 +1,14 @@
1
+ import { FieldBuilder } from "./field.builder.js";
2
+ import { Infer } from "./infer.js";
3
+ import { SchemaShape } from "./types.js";
4
+
5
+ export const field = {
6
+ string: () => new FieldBuilder<string>("string"),
7
+ number: () => new FieldBuilder<number>("number"),
8
+ boolean: () => new FieldBuilder<boolean>("boolean"),
9
+ object: <T extends Record<string, FieldBuilder>>(fields: T) =>
10
+ new FieldBuilder<T>("object", { shape: fields }),
11
+ array: (itemType: FieldBuilder) => new FieldBuilder("array", { itemType }),
12
+ ref: <S extends SchemaShape>(refType: string) =>
13
+ new FieldBuilder<Infer<S>>("ref", { refType }),
14
+ };
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export type { Infer } from "./infer.js";
2
+ export { field } from "./field.js";
3
+ export { createSchema, getSchemaForType, getAllSchemas } from "./schema.js";
4
+ export type * from "./types.js";
5
+ export * from "./components.js";
6
+ export * from "./validation/index.js";
7
+ export * from "./field.builder.js";
package/src/infer.ts ADDED
@@ -0,0 +1,34 @@
1
+ import { SchemaShape } from "./types.js";
2
+
3
+ type InferField<T> = T extends { _type: infer U }
4
+ ? U
5
+ : T extends { type: "string" }
6
+ ? string
7
+ : T extends { type: "number" }
8
+ ? number
9
+ : T extends { type: "boolean" }
10
+ ? boolean
11
+ : T extends { type: "array"; itemType: infer Item }
12
+ ? InferField<Item>[]
13
+ : T extends { type: "object"; shape: infer Shape extends SchemaShape }
14
+ ? InferFromShape<Shape>
15
+ : unknown;
16
+
17
+ type IsOptional<T> = T extends { optional: true } ? true : false;
18
+
19
+ type InferFromShape<S extends SchemaShape> = {
20
+ [K in keyof S]: IsOptional<S[K]> extends true
21
+ ? InferField<S[K]> | undefined
22
+ : InferField<S[K]>;
23
+ };
24
+
25
+ type InferFromSchema<T extends { shape: SchemaShape }> = InferFromShape<
26
+ T["shape"]
27
+ >;
28
+
29
+ type IsSchema<T> = T extends { shape: SchemaShape } ? true : false;
30
+
31
+ export type Infer<T> =
32
+ IsSchema<T> extends true
33
+ ? InferFromSchema<T & { shape: SchemaShape }>
34
+ : InferFromShape<T & SchemaShape>;
package/src/pii.ts ADDED
@@ -0,0 +1,165 @@
1
+ export type PIIClassification = "none" | "low" | "high";
2
+
3
+ export type PIIAction = "encrypt" | "hash" | "clear" | "none";
4
+
5
+ export type PIILogHandling = "redact" | "omit" | "pseudonym" | "plain";
6
+
7
+ export type PiiEnforcement = "strict" | "warn" | "none";
8
+
9
+ export interface PII {
10
+ classification: PIIClassification;
11
+ action: PIIAction;
12
+ logHandling?: PIILogHandling; // How should this PII be handled in logs?
13
+ purpose?: string; // optional, for audit: e.g. "user contact", "analytics"
14
+ }
15
+
16
+ /**
17
+ * Centralized PII enforcement for field-level validation.
18
+ * Returns { shortCircuit: true } when in strict mode and value is missing for high PII.
19
+ */
20
+ export function enforcePIIField(
21
+ parentKey: string,
22
+ key: string,
23
+ value: any,
24
+ def: any,
25
+ enforcement: PiiEnforcement = "none",
26
+ errors?: string[],
27
+ logger?: { warn: (msg: string) => void }
28
+ ): { shortCircuit: boolean } {
29
+ const path = parentKey ? `${parentKey}.${key}` : key;
30
+ if (def?._pii?.classification === "high" && (def?.isRequired ?? true)) {
31
+ const missing = value === undefined || value === null || value === "";
32
+ if (missing) {
33
+ const msg = `High PII field must not be empty: ${path}`;
34
+ if (enforcement === "strict") {
35
+ errors?.push(msg);
36
+ return { shortCircuit: true };
37
+ }
38
+ if (enforcement === "warn") {
39
+ logger?.warn?.(`WARN (PII Enforcement): ${msg}`);
40
+ }
41
+ }
42
+ }
43
+ return { shortCircuit: false };
44
+ }
45
+
46
+ /**
47
+ * Apply storage-time PII transforms based on field definitions in shape.
48
+ */
49
+ export function prepareForStorage(
50
+ shape: Record<string, any>,
51
+ input: Record<string, any>,
52
+ encryptFn: (value: any) => string,
53
+ hashFn: (value: any) => string
54
+ ): Record<string, any> {
55
+ const result: any = {};
56
+ for (const key in shape) {
57
+ const def = shape[key];
58
+ if (!def) continue;
59
+ const value = input[key];
60
+ if (def._pii?.action === "encrypt") {
61
+ result[key + "Encrypted"] = encryptFn(value);
62
+ } else if (def._pii?.action === "hash") {
63
+ result[key + "Hash"] = hashFn(value);
64
+ } else {
65
+ result[key] = value;
66
+ }
67
+ }
68
+ return result;
69
+ }
70
+
71
+ /**
72
+ * Apply read-time PII transforms (e.g., decrypt) based on field definitions in shape.
73
+ */
74
+ export function prepareForRead(
75
+ shape: Record<string, any>,
76
+ stored: Record<string, any>,
77
+ decryptFn: (value: string) => any
78
+ ): Record<string, any> {
79
+ const result: any = {};
80
+ for (const key in shape) {
81
+ const def = shape[key];
82
+ if (!def) continue;
83
+ if (def._pii?.action === "encrypt") {
84
+ result[key] = decryptFn(stored[key + "Encrypted"]);
85
+ } else {
86
+ result[key] = stored[key];
87
+ }
88
+ }
89
+ return result;
90
+ }
91
+
92
+ /**
93
+ * Sanitize data for logging according to PII logHandling.
94
+ */
95
+ export function sanitizeForLog(
96
+ shape: Record<string, any>,
97
+ data: Record<string, any>,
98
+ pseudonymFn: (value: any) => string
99
+ ): Record<string, any> {
100
+ const output: any = {};
101
+ for (const key in shape) {
102
+ const def = shape[key];
103
+ if (!def) continue;
104
+ const value = data[key];
105
+ const handling = def._pii?.logHandling as PIILogHandling | undefined;
106
+ if (handling === "omit") continue;
107
+ if (handling === "redact") {
108
+ output[key] = "[REDACTED]";
109
+ } else if (handling === "pseudonym") {
110
+ output[key] = pseudonymFn(value);
111
+ } else {
112
+ output[key] = value;
113
+ }
114
+ }
115
+ return output;
116
+ }
117
+
118
+ /**
119
+ * Produce a PII audit list for the given shape.
120
+ */
121
+ export function getPiiAudit(shape: Record<string, any>): Array<{
122
+ field: string;
123
+ classification: PIIClassification;
124
+ action: PIIAction;
125
+ logHandling?: PIILogHandling;
126
+ purpose?: string;
127
+ }> {
128
+ const piiFields: Array<any> = [];
129
+ for (const key in shape) {
130
+ const def = shape[key];
131
+ if (!def) continue;
132
+ if (def._pii && def._pii.classification !== "none") {
133
+ piiFields.push({
134
+ field: key,
135
+ classification: def._pii.classification,
136
+ action: def._pii.action,
137
+ logHandling: def._pii.logHandling,
138
+ purpose: def._pii.purpose,
139
+ });
140
+ }
141
+ }
142
+ return piiFields;
143
+ }
144
+
145
+ /**
146
+ * Scrub PII fields for delete/retention workflows.
147
+ */
148
+ export function scrubPiiForDelete(
149
+ shape: Record<string, any>,
150
+ stored: Record<string, any>
151
+ ): Record<string, any> {
152
+ const result: any = { ...stored };
153
+ for (const key in shape) {
154
+ const def = shape[key];
155
+ if (!def) continue;
156
+ if (def._pii?.action === "encrypt") {
157
+ result[key + "Encrypted"] = null;
158
+ } else if (def._pii?.action === "hash") {
159
+ result[key + "Hash"] = null;
160
+ } else if (def._pii?.action === "clear") {
161
+ result[key] = null;
162
+ }
163
+ }
164
+ return result;
165
+ }