@forgezero/runtime 0.1.13 → 0.1.15

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.
@@ -132,7 +132,7 @@ var invitation = defineTemplate({
132
132
  data: ["inviter", "organisation", "link", "days"],
133
133
  sample: {
134
134
  inviter: "Aravind",
135
- organisation: "AltPilot",
135
+ organisation: "Example Company",
136
136
  link: "https://forgezero.net/invite?token=abc",
137
137
  days: "7"
138
138
  },
@@ -46,7 +46,40 @@ function restrictJsonSchema(schema, limits = DEFAULT_RESTRICTIONS) {
46
46
  if (depth > limits.maxDepth) {
47
47
  throw new SchemaError("SCHEMA_TOO_DEEP", `Nesting exceeds ${limits.maxDepth} levels.`, path || "(root)");
48
48
  }
49
- if (record.type === "object") {
49
+ const declaredType = Array.isArray(record.type) ? record.type.filter((value) => typeof value === "string") : typeof record.type === "string" ? [record.type] : [];
50
+ if (declaredType.includes("null") && declaredType.length !== 2) {
51
+ throw new SchemaError("SCHEMA_NULLABLE_INVALID", "Nullable fields must declare exactly one concrete type and null.", path || "(root)");
52
+ }
53
+ if (Array.isArray(record.oneOf) && Array.isArray(record.anyOf)) {
54
+ throw new SchemaError("SCHEMA_UNION_INVALID", "A schema cannot declare both oneOf and anyOf.", path || "(root)");
55
+ }
56
+ const unionKeyword = Array.isArray(record.oneOf) ? "oneOf" : Array.isArray(record.anyOf) ? "anyOf" : undefined;
57
+ if (unionKeyword) {
58
+ const variants = record[unionKeyword];
59
+ const literalUnion = variants.length > 0 && variants.every((variant) => typeof variant === "object" && variant !== null && Object.keys(variant).every((key) => ["const", "title", "description"].includes(key)) && typeof variant.const === "string");
60
+ if (!literalUnion) {
61
+ const discriminator = record["x-fz-discriminator"];
62
+ if (typeof discriminator !== "string" || !/^[A-Za-z_][A-Za-z0-9_-]{0,63}$/.test(discriminator) || variants.length < 2 || variants.length > 10) {
63
+ throw new SchemaError("SCHEMA_UNION_INVALID", "Tagged unions require x-fz-discriminator and between 2 and 10 closed object variants.", path || "(root)");
64
+ }
65
+ const tags = new Set;
66
+ for (const [index, variant] of variants.entries()) {
67
+ if (typeof variant !== "object" || variant === null || Array.isArray(variant)) {
68
+ throw new SchemaError("SCHEMA_UNION_INVALID", "Every tagged-union variant must be an object.", path || "(root)");
69
+ }
70
+ const branch = variant;
71
+ const properties = branch.properties;
72
+ const tag = properties?.[discriminator]?.const;
73
+ const required = Array.isArray(branch.required) ? branch.required : [];
74
+ if (branch.type !== "object" || branch.additionalProperties !== false || typeof tag !== "string" || !required.includes(discriminator) || tags.has(tag)) {
75
+ throw new SchemaError("SCHEMA_UNION_INVALID", `Tagged-union variant ${index + 1} must require a unique string const at "${discriminator}".`, path || "(root)");
76
+ }
77
+ tags.add(tag);
78
+ }
79
+ }
80
+ variants.forEach((variant, index) => walk(variant, depth + 1, `${path || "(root)"}.${unionKeyword}[${index}]`));
81
+ }
82
+ if (declaredType.includes("object")) {
50
83
  const properties = record.properties ?? {};
51
84
  const names = Object.keys(properties);
52
85
  fields += names.length;
@@ -60,7 +93,7 @@ function restrictJsonSchema(schema, limits = DEFAULT_RESTRICTIONS) {
60
93
  walk(properties[name], depth + 1, path ? `${path}.${name}` : name);
61
94
  }
62
95
  }
63
- if (record.type === "array") {
96
+ if (declaredType.includes("array")) {
64
97
  const maximum = record.maxItems;
65
98
  if (!Number.isInteger(maximum) || maximum < 0) {
66
99
  throw new SchemaError("SCHEMA_ARRAY_UNBOUNDED", "Arrays must declare a finite non-negative integer maxItems.", path || "(root)");
@@ -115,30 +148,101 @@ function describeJsonSchema(schema, prefix = "") {
115
148
  minimum: property.minimum,
116
149
  maximum: property.maximum,
117
150
  pattern: property.pattern,
151
+ minItems: property.minItems,
152
+ maxItems: property.maxItems,
153
+ nullable: Array.isArray(property.type) && property.type.includes("null") ? true : undefined,
118
154
  derive: deriveAnnotation(property)
119
155
  };
120
156
  if (kind === "enum") {
121
- return { ...field, options: property.enum ?? property.anyOf };
157
+ return { ...field, options: enumOptions(property) };
122
158
  }
159
+ if (kind === "union")
160
+ return describeUnion(property, field);
123
161
  if (kind === "object") {
124
162
  return { ...field, items: describeJsonSchema(property, path) };
125
163
  }
126
164
  if (kind === "array" && property.items) {
165
+ const itemSchema = property.items;
127
166
  return {
128
167
  ...field,
129
- items: describeJsonSchema(property.items, path)
168
+ item: describeProperty(itemSchema, `${path}[]`, "Item", true),
169
+ items: itemSchema.type === "object" ? describeJsonSchema(itemSchema, `${path}[]`) : undefined
130
170
  };
131
171
  }
132
172
  return field;
133
173
  });
134
174
  }
175
+ function describeProperty(property, path, label, required) {
176
+ const kind = fieldKind(property);
177
+ const field = {
178
+ path,
179
+ label: property.title ?? label,
180
+ kind,
181
+ required,
182
+ description: property.description,
183
+ writeOnly: property.writeOnly === true ? true : undefined,
184
+ default: property.default,
185
+ format: property.format,
186
+ minLength: property.minLength,
187
+ maxLength: property.maxLength,
188
+ minimum: property.minimum,
189
+ maximum: property.maximum,
190
+ pattern: property.pattern,
191
+ minItems: property.minItems,
192
+ maxItems: property.maxItems,
193
+ derive: deriveAnnotation(property)
194
+ };
195
+ if (kind === "enum")
196
+ return { ...field, options: enumOptions(property) };
197
+ if (kind === "union")
198
+ return describeUnion(property, field);
199
+ if (kind === "object")
200
+ return { ...field, items: describeJsonSchema(property, path) };
201
+ if (kind === "array" && property.items) {
202
+ const itemSchema = property.items;
203
+ return {
204
+ ...field,
205
+ item: describeProperty(itemSchema, `${path}[]`, "Item", true),
206
+ items: itemSchema.type === "object" ? describeJsonSchema(itemSchema, `${path}[]`) : undefined
207
+ };
208
+ }
209
+ return field;
210
+ }
211
+ function enumOptions(property) {
212
+ if (Array.isArray(property.enum))
213
+ return property.enum.filter((value) => typeof value === "string");
214
+ if (Array.isArray(property.anyOf))
215
+ return property.anyOf.flatMap((value) => typeof value === "object" && value !== null && typeof value.const === "string" ? [value.const] : []);
216
+ return [];
217
+ }
218
+ function describeUnion(property, field) {
219
+ const discriminator = property["x-fz-discriminator"];
220
+ const raw = Array.isArray(property.oneOf) ? property.oneOf : property.anyOf;
221
+ return {
222
+ ...field,
223
+ discriminator,
224
+ variants: raw.map((variant) => {
225
+ const properties = variant.properties;
226
+ const value = properties[discriminator].const;
227
+ return {
228
+ value,
229
+ label: variant.title ?? humanise(value),
230
+ fields: describeJsonSchema(variant, field.path)
231
+ };
232
+ })
233
+ };
234
+ }
135
235
  function fieldKind(property) {
136
236
  if (Array.isArray(property.enum))
137
237
  return "enum";
138
238
  if (Array.isArray(property.anyOf) && property.anyOf.every((m) => typeof m === "object" && m !== null && ("const" in m))) {
139
239
  return "enum";
140
240
  }
141
- switch (property.type) {
241
+ if ((Array.isArray(property.oneOf) || Array.isArray(property.anyOf)) && typeof property["x-fz-discriminator"] === "string") {
242
+ return "union";
243
+ }
244
+ const declared = Array.isArray(property.type) ? property.type.find((value) => value !== "null") : property.type;
245
+ switch (declared) {
142
246
  case "string":
143
247
  return "string";
144
248
  case "number":
@@ -160,12 +264,17 @@ function humanise(name) {
160
264
  return spaced.charAt(0).toUpperCase() + spaced.slice(1);
161
265
  }
162
266
  function readableFields(fields) {
163
- return fields.filter((field) => !field.writeOnly).map((field) => field.items ? { ...field, items: readableFields(field.items) } : field);
267
+ return fields.filter((field) => !field.writeOnly).map((field) => ({
268
+ ...field,
269
+ items: field.items ? readableFields(field.items) : field.items,
270
+ variants: field.variants?.map((variant) => ({ ...variant, fields: readableFields(variant.fields) }))
271
+ }));
164
272
  }
165
273
  function writeOnlyPaths(fields) {
166
274
  return fields.flatMap((field) => [
167
275
  ...field.writeOnly ? [field.path] : [],
168
- ...field.items ? writeOnlyPaths(field.items) : []
276
+ ...field.items ? writeOnlyPaths(field.items) : [],
277
+ ...field.variants ? field.variants.flatMap((variant) => writeOnlyPaths(variant.fields)) : []
169
278
  ]);
170
279
  }
171
280
  var SCHEMA_VERSION = 1;
package/dist/schema.d.ts CHANGED
@@ -44,7 +44,7 @@ export interface Restrictions {
44
44
  forbidden: readonly string[];
45
45
  }
46
46
  export declare const DEFAULT_RESTRICTIONS: Restrictions;
47
- export type FieldKind = 'string' | 'number' | 'integer' | 'boolean' | 'enum' | 'array' | 'object' | 'unknown';
47
+ export type FieldKind = 'string' | 'number' | 'integer' | 'boolean' | 'enum' | 'union' | 'array' | 'object' | 'unknown';
48
48
  /**
49
49
  * One rendered field. Deliberately flat and framework-agnostic: the admin UI
50
50
  * maps this to inputs, and nothing here knows what a component is.
@@ -72,6 +72,19 @@ export interface FormField {
72
72
  pattern?: string;
73
73
  /** `array` and `object` only — the shape of each child. */
74
74
  items?: readonly FormField[];
75
+ /** A bounded tagged union. The discriminator is always a required string literal. */
76
+ discriminator?: string;
77
+ variants?: readonly {
78
+ value: string;
79
+ label: string;
80
+ fields: readonly FormField[];
81
+ }[];
82
+ /** `array` only — one recursively renderable item descriptor. */
83
+ item?: FormField;
84
+ minItems?: number;
85
+ maxItems?: number;
86
+ /** A nullable field still retains its concrete kind. */
87
+ nullable?: boolean;
75
88
  /**
76
89
  * ForgeZero generates this value; the tenant never types it and never holds
77
90
  * it. Carried through from the schema's `x-fz-derive` annotation.
package/dist/schema.js CHANGED
@@ -46,7 +46,40 @@ function restrictJsonSchema(schema, limits = DEFAULT_RESTRICTIONS) {
46
46
  if (depth > limits.maxDepth) {
47
47
  throw new SchemaError("SCHEMA_TOO_DEEP", `Nesting exceeds ${limits.maxDepth} levels.`, path || "(root)");
48
48
  }
49
- if (record.type === "object") {
49
+ const declaredType = Array.isArray(record.type) ? record.type.filter((value) => typeof value === "string") : typeof record.type === "string" ? [record.type] : [];
50
+ if (declaredType.includes("null") && declaredType.length !== 2) {
51
+ throw new SchemaError("SCHEMA_NULLABLE_INVALID", "Nullable fields must declare exactly one concrete type and null.", path || "(root)");
52
+ }
53
+ if (Array.isArray(record.oneOf) && Array.isArray(record.anyOf)) {
54
+ throw new SchemaError("SCHEMA_UNION_INVALID", "A schema cannot declare both oneOf and anyOf.", path || "(root)");
55
+ }
56
+ const unionKeyword = Array.isArray(record.oneOf) ? "oneOf" : Array.isArray(record.anyOf) ? "anyOf" : undefined;
57
+ if (unionKeyword) {
58
+ const variants = record[unionKeyword];
59
+ const literalUnion = variants.length > 0 && variants.every((variant) => typeof variant === "object" && variant !== null && Object.keys(variant).every((key) => ["const", "title", "description"].includes(key)) && typeof variant.const === "string");
60
+ if (!literalUnion) {
61
+ const discriminator = record["x-fz-discriminator"];
62
+ if (typeof discriminator !== "string" || !/^[A-Za-z_][A-Za-z0-9_-]{0,63}$/.test(discriminator) || variants.length < 2 || variants.length > 10) {
63
+ throw new SchemaError("SCHEMA_UNION_INVALID", "Tagged unions require x-fz-discriminator and between 2 and 10 closed object variants.", path || "(root)");
64
+ }
65
+ const tags = new Set;
66
+ for (const [index, variant] of variants.entries()) {
67
+ if (typeof variant !== "object" || variant === null || Array.isArray(variant)) {
68
+ throw new SchemaError("SCHEMA_UNION_INVALID", "Every tagged-union variant must be an object.", path || "(root)");
69
+ }
70
+ const branch = variant;
71
+ const properties = branch.properties;
72
+ const tag = properties?.[discriminator]?.const;
73
+ const required = Array.isArray(branch.required) ? branch.required : [];
74
+ if (branch.type !== "object" || branch.additionalProperties !== false || typeof tag !== "string" || !required.includes(discriminator) || tags.has(tag)) {
75
+ throw new SchemaError("SCHEMA_UNION_INVALID", `Tagged-union variant ${index + 1} must require a unique string const at "${discriminator}".`, path || "(root)");
76
+ }
77
+ tags.add(tag);
78
+ }
79
+ }
80
+ variants.forEach((variant, index) => walk(variant, depth + 1, `${path || "(root)"}.${unionKeyword}[${index}]`));
81
+ }
82
+ if (declaredType.includes("object")) {
50
83
  const properties = record.properties ?? {};
51
84
  const names = Object.keys(properties);
52
85
  fields += names.length;
@@ -60,7 +93,7 @@ function restrictJsonSchema(schema, limits = DEFAULT_RESTRICTIONS) {
60
93
  walk(properties[name], depth + 1, path ? `${path}.${name}` : name);
61
94
  }
62
95
  }
63
- if (record.type === "array") {
96
+ if (declaredType.includes("array")) {
64
97
  const maximum = record.maxItems;
65
98
  if (!Number.isInteger(maximum) || maximum < 0) {
66
99
  throw new SchemaError("SCHEMA_ARRAY_UNBOUNDED", "Arrays must declare a finite non-negative integer maxItems.", path || "(root)");
@@ -115,30 +148,101 @@ function describeJsonSchema(schema, prefix = "") {
115
148
  minimum: property.minimum,
116
149
  maximum: property.maximum,
117
150
  pattern: property.pattern,
151
+ minItems: property.minItems,
152
+ maxItems: property.maxItems,
153
+ nullable: Array.isArray(property.type) && property.type.includes("null") ? true : undefined,
118
154
  derive: deriveAnnotation(property)
119
155
  };
120
156
  if (kind === "enum") {
121
- return { ...field, options: property.enum ?? property.anyOf };
157
+ return { ...field, options: enumOptions(property) };
122
158
  }
159
+ if (kind === "union")
160
+ return describeUnion(property, field);
123
161
  if (kind === "object") {
124
162
  return { ...field, items: describeJsonSchema(property, path) };
125
163
  }
126
164
  if (kind === "array" && property.items) {
165
+ const itemSchema = property.items;
127
166
  return {
128
167
  ...field,
129
- items: describeJsonSchema(property.items, path)
168
+ item: describeProperty(itemSchema, `${path}[]`, "Item", true),
169
+ items: itemSchema.type === "object" ? describeJsonSchema(itemSchema, `${path}[]`) : undefined
130
170
  };
131
171
  }
132
172
  return field;
133
173
  });
134
174
  }
175
+ function describeProperty(property, path, label, required) {
176
+ const kind = fieldKind(property);
177
+ const field = {
178
+ path,
179
+ label: property.title ?? label,
180
+ kind,
181
+ required,
182
+ description: property.description,
183
+ writeOnly: property.writeOnly === true ? true : undefined,
184
+ default: property.default,
185
+ format: property.format,
186
+ minLength: property.minLength,
187
+ maxLength: property.maxLength,
188
+ minimum: property.minimum,
189
+ maximum: property.maximum,
190
+ pattern: property.pattern,
191
+ minItems: property.minItems,
192
+ maxItems: property.maxItems,
193
+ derive: deriveAnnotation(property)
194
+ };
195
+ if (kind === "enum")
196
+ return { ...field, options: enumOptions(property) };
197
+ if (kind === "union")
198
+ return describeUnion(property, field);
199
+ if (kind === "object")
200
+ return { ...field, items: describeJsonSchema(property, path) };
201
+ if (kind === "array" && property.items) {
202
+ const itemSchema = property.items;
203
+ return {
204
+ ...field,
205
+ item: describeProperty(itemSchema, `${path}[]`, "Item", true),
206
+ items: itemSchema.type === "object" ? describeJsonSchema(itemSchema, `${path}[]`) : undefined
207
+ };
208
+ }
209
+ return field;
210
+ }
211
+ function enumOptions(property) {
212
+ if (Array.isArray(property.enum))
213
+ return property.enum.filter((value) => typeof value === "string");
214
+ if (Array.isArray(property.anyOf))
215
+ return property.anyOf.flatMap((value) => typeof value === "object" && value !== null && typeof value.const === "string" ? [value.const] : []);
216
+ return [];
217
+ }
218
+ function describeUnion(property, field) {
219
+ const discriminator = property["x-fz-discriminator"];
220
+ const raw = Array.isArray(property.oneOf) ? property.oneOf : property.anyOf;
221
+ return {
222
+ ...field,
223
+ discriminator,
224
+ variants: raw.map((variant) => {
225
+ const properties = variant.properties;
226
+ const value = properties[discriminator].const;
227
+ return {
228
+ value,
229
+ label: variant.title ?? humanise(value),
230
+ fields: describeJsonSchema(variant, field.path)
231
+ };
232
+ })
233
+ };
234
+ }
135
235
  function fieldKind(property) {
136
236
  if (Array.isArray(property.enum))
137
237
  return "enum";
138
238
  if (Array.isArray(property.anyOf) && property.anyOf.every((m) => typeof m === "object" && m !== null && ("const" in m))) {
139
239
  return "enum";
140
240
  }
141
- switch (property.type) {
241
+ if ((Array.isArray(property.oneOf) || Array.isArray(property.anyOf)) && typeof property["x-fz-discriminator"] === "string") {
242
+ return "union";
243
+ }
244
+ const declared = Array.isArray(property.type) ? property.type.find((value) => value !== "null") : property.type;
245
+ switch (declared) {
142
246
  case "string":
143
247
  return "string";
144
248
  case "number":
@@ -160,12 +264,17 @@ function humanise(name) {
160
264
  return spaced.charAt(0).toUpperCase() + spaced.slice(1);
161
265
  }
162
266
  function readableFields(fields) {
163
- return fields.filter((field) => !field.writeOnly).map((field) => field.items ? { ...field, items: readableFields(field.items) } : field);
267
+ return fields.filter((field) => !field.writeOnly).map((field) => ({
268
+ ...field,
269
+ items: field.items ? readableFields(field.items) : field.items,
270
+ variants: field.variants?.map((variant) => ({ ...variant, fields: readableFields(variant.fields) }))
271
+ }));
164
272
  }
165
273
  function writeOnlyPaths(fields) {
166
274
  return fields.flatMap((field) => [
167
275
  ...field.writeOnly ? [field.path] : [],
168
- ...field.items ? writeOnlyPaths(field.items) : []
276
+ ...field.items ? writeOnlyPaths(field.items) : [],
277
+ ...field.variants ? field.variants.flatMap((variant) => writeOnlyPaths(variant.fields)) : []
169
278
  ]);
170
279
  }
171
280
  var SCHEMA_VERSION = 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgezero/runtime",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -75,10 +75,6 @@
75
75
  "types": "./dist/pipeline.d.ts",
76
76
  "default": "./dist/pipeline.js"
77
77
  },
78
- "./compliance": {
79
- "types": "./dist/compliance.d.ts",
80
- "default": "./dist/compliance.js"
81
- },
82
78
  "./finance/discounts": {
83
79
  "types": "./dist/finance/discounts.d.ts",
84
80
  "default": "./dist/finance/discounts.js"
@@ -91,62 +87,10 @@
91
87
  "types": "./dist/finance/storage.d.ts",
92
88
  "default": "./dist/finance/storage.js"
93
89
  },
94
- "./finance/custody": {
95
- "types": "./dist/finance/custody.d.ts",
96
- "default": "./dist/finance/custody.js"
97
- },
98
90
  "./finance/tax": {
99
91
  "types": "./dist/finance/tax.d.ts",
100
92
  "default": "./dist/finance/tax.js"
101
93
  },
102
- "./finance/derive": {
103
- "types": "./dist/finance/derive.d.ts",
104
- "default": "./dist/finance/derive.js"
105
- },
106
- "./finance/venues": {
107
- "types": "./dist/finance/venues.d.ts",
108
- "default": "./dist/finance/venues.js"
109
- },
110
- "./finance/ledger": {
111
- "types": "./dist/finance/ledger.d.ts",
112
- "default": "./dist/finance/ledger.js"
113
- },
114
- "./finance/rates": {
115
- "types": "./dist/finance/rates.d.ts",
116
- "default": "./dist/finance/rates.js"
117
- },
118
- "./finance/transfers": {
119
- "types": "./dist/finance/transfers.d.ts",
120
- "default": "./dist/finance/transfers.js"
121
- },
122
- "./finance/chain": {
123
- "types": "./dist/finance/chain.d.ts",
124
- "default": "./dist/finance/chain.js"
125
- },
126
- "./finance/chain-addresses": {
127
- "types": "./dist/finance/chain-addresses.d.ts",
128
- "default": "./dist/finance/chain-addresses.js"
129
- },
130
- "./finance/chain-deposits": {
131
- "types": "./dist/finance/chain-deposits.d.ts",
132
- "default": "./dist/finance/chain-deposits.js"
133
- },
134
- "./finance/chain-withdrawals": {
135
- "types": "./dist/finance/chain-withdrawals.d.ts",
136
- "default": "./dist/finance/chain-withdrawals.js"
137
- },
138
- "./finance/chain-reconcile": {
139
- "types": "./dist/finance/chain-reconcile.d.ts",
140
- "default": "./dist/finance/chain-reconcile.js"
141
- },
142
- "./finance/market": {
143
- "types": "./dist/finance/market.d.ts",
144
- "default": "./dist/finance/market.js"
145
- },
146
- "./finance/commission": {
147
- "types": "./dist/finance/commission.d.ts",
148
- "default": "./dist/finance/commission.js"
149
- },
150
94
  "./passkey": {
151
95
  "types": "./dist/passkey.d.ts",
152
96
  "default": "./dist/passkey.js"
@@ -190,7 +134,7 @@
190
134
  "prepublishOnly": "bun ../tools/package-task.ts prepublish runtime"
191
135
  },
192
136
  "dependencies": {
193
- "@forgezero/access": "^0.1.8"
137
+ "@forgezero/access": "^0.1.10"
194
138
  },
195
139
  "peerDependencies": {
196
140
  "@noble/ciphers": "^2.2.0",
@@ -1,172 +0,0 @@
1
- /**
2
- * Screening — the stage that already has a seat.
3
- *
4
- * `@forgezero/runtime/finance/transfers` reserved position zero and has been running a
5
- * pass-everything placeholder there since before this file existed. That
6
- * ordering is the whole reason this is a drop-in: nothing about deposits or
7
- * withdrawals changes, and the audit trail already records the stage running.
8
- *
9
- * ## Screening is a DECISION RECORD, not a boolean
10
- *
11
- * The value of compliance work six months later is being able to say why a
12
- * transfer was allowed — not just that it was. A screen that returns true or
13
- * false gives an auditor nothing, so every check produces a verdict with the
14
- * rules that fired, the risk it scored and the list version it was screened
15
- * against. That record is the deliverable; the refusal is a side effect.
16
- *
17
- * ## Fail CLOSED on an unavailable list, and say so
18
- *
19
- * A sanctions list that cannot be reached is not "no hits". Treating an
20
- * unreachable provider as a pass is how sanctioned money moves during an
21
- * outage, and it is invisible afterwards because the trail says allowed. So an
22
- * unavailable list refuses with a retryable status — the transfer waits rather
23
- * than proceeding unscreened.
24
- *
25
- * ## What this deliberately does NOT do
26
- *
27
- * No list is bundled. A sanctions list embedded in a package is out of date the
28
- * day it publishes, and being out of date is the only failure mode that
29
- * matters. The provider is injected, and `staticList` exists for tests and for
30
- * an operator's own denylist — never as the primary source.
31
- */
32
- export declare class ComplianceError extends Error {
33
- readonly code: 'LIST_UNAVAILABLE' | 'BAD_SUBJECT';
34
- constructor(code: 'LIST_UNAVAILABLE' | 'BAD_SUBJECT', message: string);
35
- }
36
- export declare const RISK_LEVELS: readonly ["low", "medium", "high", "prohibited"];
37
- export type RiskLevel = (typeof RISK_LEVELS)[number];
38
- export declare const VERIFICATION_TIERS: readonly ["none", "basic", "verified", "enhanced"];
39
- export type VerificationTier = (typeof VERIFICATION_TIERS)[number];
40
- export interface Subject {
41
- /** The account being screened. */
42
- owner: string;
43
- /** Counterparty address, for a transfer. */
44
- address?: string;
45
- network?: string;
46
- /** Value in USD, so a threshold rule can fire. */
47
- usdValue?: number;
48
- direction?: 'deposit' | 'withdrawal';
49
- /** How far the account has verified. Drives the tier rules. */
50
- tier?: VerificationTier;
51
- /** Anything a rule wants — country, name, date of birth. */
52
- attributes?: Record<string, string>;
53
- }
54
- export interface ListEntry {
55
- /** Address, name or identifier this entry matches. */
56
- value: string;
57
- kind: 'address' | 'name' | 'country';
58
- /** Which list it came from — OFAC, an internal denylist, a chain analytics feed. */
59
- source: string;
60
- reason?: string;
61
- }
62
- export interface ScreeningList {
63
- /** Bumped whenever the list content changes. Recorded on every verdict. */
64
- readonly version: string;
65
- /** Throws `ComplianceError('LIST_UNAVAILABLE')` rather than returning empty. */
66
- match(subject: Subject): Promise<ListEntry[]>;
67
- }
68
- export interface RuleHit {
69
- rule: string;
70
- risk: RiskLevel;
71
- detail: string;
72
- }
73
- export interface Rule {
74
- name: string;
75
- /** Returns a hit, or undefined when the rule does not fire. */
76
- check(subject: Subject): RuleHit | undefined | Promise<RuleHit | undefined>;
77
- }
78
- /**
79
- * A transfer above a threshold for the tier the account has reached.
80
- *
81
- * Tiered rather than one global limit, because the whole point of verification
82
- * is that it raises what an account may move. A single threshold means either
83
- * verified accounts are throttled or unverified ones are not.
84
- */
85
- export declare const tierLimitRule: (limits: Partial<Record<VerificationTier, number>>) => Rule;
86
- /** A jurisdiction the platform will not serve. */
87
- export declare const countryRule: (prohibited: readonly string[]) => Rule;
88
- /**
89
- * A deposit from an address that has never been seen, above a threshold.
90
- *
91
- * Weak on its own and useful in combination — it is the kind of signal that
92
- * raises a transfer to review rather than refusing it, which is why it scores
93
- * `medium` and not higher.
94
- */
95
- export declare const newCounterpartyRule: (args: {
96
- aboveUsd: number;
97
- isKnown: (address: string) => boolean | Promise<boolean>;
98
- }) => Rule;
99
- export interface Verdict {
100
- decision: 'allow' | 'review' | 'refuse';
101
- risk: RiskLevel;
102
- hits: RuleHit[];
103
- listMatches: ListEntry[];
104
- /** Which list version this was screened against. The auditable part. */
105
- listVersion: string;
106
- screenedAtMs: number;
107
- subject: Subject;
108
- }
109
- export interface ScreenOptions {
110
- list?: ScreeningList;
111
- rules?: readonly Rule[];
112
- /** At or above this, the transfer is held for a human rather than refused. */
113
- reviewAt?: RiskLevel;
114
- now?: () => number;
115
- }
116
- /**
117
- * Screen a subject and produce a verdict.
118
- *
119
- * A list match is always `prohibited` — that is what a sanctions list means,
120
- * and softening it to "high risk, review it" is the decision nobody should be
121
- * able to make quietly in a config file. Rules can only ever raise the level
122
- * arrived at, never lower it.
123
- */
124
- export declare function screen(subject: Subject, options?: ScreenOptions): Promise<Verdict>;
125
- export interface StageOptions extends ScreenOptions {
126
- /** Called for every verdict, allowed or not. The case record. */
127
- record?: (verdict: Verdict) => void | Promise<void>;
128
- /** Whether a `review` verdict has already been approved by a human. */
129
- isApproved?: (subject: Subject) => boolean | Promise<boolean>;
130
- }
131
- /**
132
- * The screening stage, shaped for `@forgezero/runtime/finance/transfers`.
133
- *
134
- * Drops into the seat that was reserved at order zero. Nothing else changes —
135
- * which was the entire point of building the pipeline before the policy.
136
- *
137
- * EVERY verdict is recorded, including allowances. A trail of refusals answers
138
- * "what did we stop" and not "what did we decide", and the second question is
139
- * the one an auditor asks.
140
- */
141
- export declare function screeningStage(options?: StageOptions): {
142
- name: string;
143
- order: number;
144
- run(transfer: {
145
- owner: string;
146
- direction: "deposit" | "withdrawal";
147
- address?: string;
148
- network?: string;
149
- amount: {
150
- units: bigint;
151
- asset: string;
152
- };
153
- context?: Record<string, unknown>;
154
- }): Promise<{
155
- screenedAtMs: number;
156
- risk: "low" | "medium" | "high" | "prohibited";
157
- listVersion: string;
158
- }>;
159
- };
160
- /**
161
- * A list held in memory.
162
- *
163
- * For tests, and for an operator's own denylist alongside a real feed — never
164
- * as the primary source. A sanctions list bundled into a package is out of date
165
- * the day it publishes, and being out of date is the only failure mode that
166
- * matters here.
167
- */
168
- export declare function staticList(entries: readonly ListEntry[], version?: string): ScreeningList;
169
- /** Combine several lists. Any one being unavailable fails the whole screen. */
170
- export declare function combineLists(...lists: readonly ScreeningList[]): ScreeningList;
171
- /** A list that is not configured yet. Refuses, so nothing runs unscreened by accident. */
172
- export declare const unavailableList: (reason: string) => ScreeningList;