mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-06 06:28:11 +00:00
OpenErrand 0.1: an errand on a website with no API, with the human steps kept human (#227)
* OpenErrand 0.1: an errand on a website with no API, with the human steps kept human docs/openerrand.md mints OpenErrand: one JSON file per errand (register an account, download a transcript) naming the site, the inputs with a sensitivity class and ordered sources (document, vault, prompt, generate, derive, candidate, literal), field rules matched by id then label, page and wait steps, five human gates a runner never performs (declare, identity-proofing, code, mail, captcha), outcomes, the never-retried shared secret, vault and download outputs, hand-off cards that may name only public inputs, the publisher index at /.well-known/openerrand.json, and thirteen runner rules. The worked example is the MyFTB business registration that cli-tools `ftb` performs (profullstack/cli-tools#125), with no personal data. - @logicsrc/schemas: openerrand + openerrand-index schemas and fixtures - @logicsrc/validators: semantic checks (references, templates, no personal or secret input on a card) and tests that validate the spec's own examples - logicsrc-web: registry entry (process family), /openerrand landing page, the example and the index served as static files, contract tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * OpenErrand: hand-off cards stay on the surface that owns the data Anthony's ruling: tax and finance data never touches a social or promotion tool, and nothing is sent to a CPA or preparer. - Hand-off cards are delivered only on the surface that owns the errand's data (for a tax or finance errand, the principal's finance app through its CLI, PWA, MCP server or API, such as CoinPay, or the runner's terminal), never a social, promotion or third-party posting service, and never to anyone but the principal. A card for an errand with personal or secret inputs does not leave that surface. Runner rule 9 says the same. - The run record and the sample run name the card by an opaque id (pin-letter/7f3k2q) instead of a mynaposter.com URL; the myna mention is gone. - `principal: represented` no longer cites a preparer with a power of attorney. - The FTB card's last step no longer suggests sending the PIN to someone else. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * OpenErrand: user-agent rule, captcha solver policy, reference runner note Anthony's answers on #227 ("go with your recommendations"): - Rule 11: a runner may run headless with a normal desktop browser user agent (dropping HeadlessChrome) and nothing more: no fingerprint spoofing beyond the UA string, no stealth plugins, no solving or evading a bot challenge. A challenge the browser completes itself is a wait step; any other is a captcha gate. - Captcha solvers: new site.sector and captcha step `solver` (forbidden by default | allowed). Never allowed on government, tax, financial, healthcare or identity-provider sites, nor on any errand with a declare or identity-proofing step or a secret input; elsewhere only when the file says so, with every use logged. The validator rejects `allowed` in the forbidden set or without a stated sector; six new tests. The FTB example states sector "tax". - Reference runner: @logicsrc/openerrand / `logicsrc errand run`, marked in progress; ftb stays the runner the example was taken from. - Name stays OpenErrand; family stays Agents and process. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d38db62e8b
commit
1d69dc3804
19 changed files with 2231 additions and 3 deletions
|
|
@ -18,6 +18,11 @@ Schema families include:
|
|||
- **OpenRental draft** — the `openrental` export describes listings of OpenAgent
|
||||
profiles and OpenSwarm file-key references, with metadata and CoinPay rental
|
||||
offers. `@logicsrc/validators` also checks membership and rental references.
|
||||
- **OpenErrand** - the `openerrand` and `openerrand-index` exports describe an
|
||||
errand a runner performs on a website with no API, and a publisher's list of
|
||||
them. Fixtures are in `fixtures/openerrand`. `@logicsrc/validators` also checks
|
||||
references, templates, and that no hand-off card names a personal or secret
|
||||
input. See `docs/openerrand.md`.
|
||||
|
||||
## Install
|
||||
|
||||
|
|
|
|||
236
packages/schemas/fixtures/openerrand/ftb-register-business.json
Normal file
236
packages/schemas/fixtures/openerrand/ftb-register-business.json
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
{
|
||||
"type": "logicsrc.openerrand",
|
||||
"version": "0.1",
|
||||
"id": "https://logicsrc.com/examples/openerrand/ftb-register-business.json",
|
||||
"name": "ftb-register-business",
|
||||
"title": "Register a MyFTB business account",
|
||||
"description": "Creates a MyFTB account for a California S corporation at the Franchise Tax Board, proving the business with a figure from a filed Form 100S. FTB then mails a PIN; activation is a second errand.",
|
||||
"publisher": "https://logicsrc.com/.well-known/openprofile.md",
|
||||
"updated": "2026-10-04T00:00:00Z",
|
||||
"reference": "https://github.com/profullstack/cli-tools/pull/125",
|
||||
"principal": "self",
|
||||
"site": {
|
||||
"name": "California Franchise Tax Board (MyFTB)",
|
||||
"sector": "tax",
|
||||
"origins": ["https://webapp.ftb.ca.gov"],
|
||||
"start": ["https://webapp.ftb.ca.gov/MyFTBAccess/Registration/NewAccount"]
|
||||
},
|
||||
"limits": { "pages": 15, "page_timeout": "PT30S", "same_page": 2 },
|
||||
"inputs": {
|
||||
"email": {
|
||||
"label": "Email address FTB writes to",
|
||||
"type": "email",
|
||||
"sensitivity": "personal",
|
||||
"required": true,
|
||||
"sources": [{ "from": "prompt" }]
|
||||
},
|
||||
"phone": {
|
||||
"label": "Mobile number FTB texts a verification code to",
|
||||
"type": "string",
|
||||
"pattern": "^[0-9]{10}$",
|
||||
"sensitivity": "personal",
|
||||
"required": true,
|
||||
"sources": [{ "from": "prompt" }]
|
||||
},
|
||||
"first_name": {
|
||||
"label": "Representative's first name",
|
||||
"type": "string",
|
||||
"max_length": 11,
|
||||
"sensitivity": "personal",
|
||||
"sources": [{ "from": "document", "form": "CA 540", "field": "first name", "pick": "newest" }]
|
||||
},
|
||||
"last_name": {
|
||||
"label": "Representative's last name",
|
||||
"type": "string",
|
||||
"max_length": 13,
|
||||
"sensitivity": "personal",
|
||||
"sources": [{ "from": "document", "form": "CA 540", "field": "last name", "pick": "newest" }]
|
||||
},
|
||||
"street": {
|
||||
"label": "Street address on the newest return",
|
||||
"type": "string",
|
||||
"sensitivity": "personal",
|
||||
"sources": [{ "from": "document", "form": "CA 540", "field": "street address", "pick": "newest" }]
|
||||
},
|
||||
"address_numbers": {
|
||||
"label": "The numbers in the address on file",
|
||||
"type": "string",
|
||||
"sensitivity": "personal",
|
||||
"sources": [{ "from": "derive", "input": "street", "transform": "digits" }]
|
||||
},
|
||||
"zip": {
|
||||
"label": "ZIP code on file",
|
||||
"type": "string",
|
||||
"sensitivity": "personal",
|
||||
"sources": [{ "from": "document", "form": "CA 540", "field": "ZIP code", "pick": "newest", "transform": "first:5" }]
|
||||
},
|
||||
"corp_id": {
|
||||
"label": "California corporation number",
|
||||
"type": "string",
|
||||
"sensitivity": "public",
|
||||
"sources": [{ "from": "document", "form": "CA 100S", "field": "California corporation number", "pick": "newest" }]
|
||||
},
|
||||
"tax_year": {
|
||||
"label": "Tax year of the return the shared secret comes from",
|
||||
"type": "integer",
|
||||
"sensitivity": "public",
|
||||
"sources": [{ "from": "candidate", "input": "net_income", "part": "year" }]
|
||||
},
|
||||
"net_income": {
|
||||
"label": "Net income for tax purposes, whole dollars",
|
||||
"type": "integer",
|
||||
"sensitivity": "secret",
|
||||
"role": "shared-secret",
|
||||
"sources": [
|
||||
{ "from": "document", "form": "CA 100S", "field": "line 20", "match": "net income for tax purposes", "years": { "back": 5, "current": false }, "transform": "whole" },
|
||||
{ "from": "document", "form": "CA 100S", "field": "line 15", "match": "net income \\(loss\\) for state purposes", "years": { "back": 5, "current": false }, "transform": "whole" }
|
||||
]
|
||||
},
|
||||
"username": {
|
||||
"label": "MyFTB user name",
|
||||
"type": "string",
|
||||
"sensitivity": "personal",
|
||||
"role": "credential",
|
||||
"sources": [
|
||||
{ "from": "vault", "key": "FTB_BUSINESS_USERNAME" },
|
||||
{ "from": "generate", "length": 15, "classes": ["lower", "digit"] }
|
||||
]
|
||||
},
|
||||
"password": {
|
||||
"label": "MyFTB password",
|
||||
"type": "string",
|
||||
"sensitivity": "secret",
|
||||
"role": "credential",
|
||||
"sources": [
|
||||
{ "from": "vault", "key": "FTB_BUSINESS_PASSWORD" },
|
||||
{ "from": "generate", "length": 24, "classes": ["lower", "upper", "digit", "special"], "special": "!#$*@" }
|
||||
]
|
||||
},
|
||||
"security": {
|
||||
"label": "Three security questions and their answers",
|
||||
"type": "qa-set",
|
||||
"count": 3,
|
||||
"sensitivity": "secret",
|
||||
"role": "credential",
|
||||
"sources": [
|
||||
{ "from": "vault", "key": "FTB_BUSINESS_SECURITY_ANSWERS" },
|
||||
{ "from": "generate", "length": 10, "classes": ["lower", "digit"] }
|
||||
]
|
||||
}
|
||||
},
|
||||
"rules": [
|
||||
{ "name": "read terms", "id": "^ReadTerms$", "types": ["checkbox"], "do": { "check": true } },
|
||||
{ "name": "accept terms", "id": "^AcceptTerms$", "types": ["checkbox"], "do": { "check": true } },
|
||||
{ "name": "first name", "id": "^FstName$", "do": { "text": "{{first_name}}" } },
|
||||
{ "name": "middle initial", "id": "^MInitial$", "do": { "skip": true } },
|
||||
{ "name": "last name", "id": "^LstName$", "do": { "text": "{{last_name}}" } },
|
||||
{ "name": "suffix", "id": "^Sffx$", "do": { "skip": true } },
|
||||
{ "name": "user name again", "id": "^ReUserName$", "do": { "text": "{{username}}" } },
|
||||
{ "name": "user name", "id": "^UserName$", "do": { "text": "{{username}}" } },
|
||||
{ "name": "email again", "id": "^ReEmail$", "do": { "text": "{{email}}" } },
|
||||
{ "name": "email", "id": "^Email$", "do": { "text": "{{email}}" } },
|
||||
{ "name": "password again", "id": "^RePassword$", "do": { "text": "{{password}}" } },
|
||||
{ "name": "password", "id": "^Password$", "do": { "text": "{{password}}" } },
|
||||
{ "name": "foreign number", "id": "^Phone_Foreign$", "do": { "skip": true } },
|
||||
{ "name": "foreign address", "id": "^Address_Foreign$|^Address_No(MailAddress|PostalCode)$", "do": { "skip": true } },
|
||||
{ "name": "security question", "label": "question", "types": ["select-one"], "do": { "choose": "security" } },
|
||||
{ "name": "security answer", "label": "question|answer", "types": ["text", "password"], "do": { "answer": "security" } },
|
||||
{ "name": "role", "label": "individual|business representative", "types": ["radio"], "do": { "check": { "label": "^\\s*business representative" } } },
|
||||
{ "name": "zip", "label": "zip|postal", "types": ["text", "tel", "number"], "do": { "text": "{{zip}}" } },
|
||||
{ "name": "address numbers", "label": "numbers in (the |your )?(business )?(mailing )?address", "types": ["text", "tel", "number"], "do": { "text": "{{address_numbers}}" } },
|
||||
{ "name": "tax year", "label": "year (of|on) the tax return|tax year", "types": ["select-one"], "do": { "select": ["^\\s*{{tax_year}}\\s*$"] } },
|
||||
{ "name": "tax year", "label": "year (of|on) the tax return|tax year", "types": ["text", "tel", "number"], "do": { "text": "{{tax_year}}" } },
|
||||
{ "name": "net income", "label": "net income|income \\(loss\\)", "types": ["text", "tel", "number"], "do": { "text": "{{net_income}}" } },
|
||||
{ "name": "company type", "label": "type of company|company type|entity type", "types": ["select-one"], "do": { "select": ["^\\s*corporation\\s*$", "corporation"] } },
|
||||
{ "name": "account number", "label": "account number|entity id|corporation (id|number)", "types": ["text", "tel", "number"], "do": { "text": "{{corp_id}}" } },
|
||||
{ "name": "form type", "label": "form type|type of (tax )?(return|form)", "types": ["select-one"], "do": { "select": ["100\\s*S\\b"] } },
|
||||
{ "name": "declaration", "label": "perjury|i declare|under penalty", "types": ["checkbox"], "do": { "gate": "declaration" } },
|
||||
{ "name": "phone", "label": "phone number", "types": ["text", "tel", "number"], "do": { "text": "{{phone}}" } },
|
||||
{ "name": "send a text", "label": "send me a text|text message", "types": ["radio"], "do": { "check": true } },
|
||||
{ "name": "verification code", "label": "verification code|security code|one[- ]time|passcode|access code|enter (the )?code", "types": ["text", "tel", "number", "password"], "do": { "gate": "text-code" } }
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"id": "bot-check",
|
||||
"kind": "wait",
|
||||
"match": { "title": "^Challenge Validation$", "selector": "#sec-cpt-if" },
|
||||
"timeout": "PT90S",
|
||||
"poll": "PT3S",
|
||||
"say": "FTB's bot check is a proof of work the page's own script solves; the runner waits for it."
|
||||
},
|
||||
{
|
||||
"id": "form",
|
||||
"kind": "page",
|
||||
"unmatched": "stop",
|
||||
"say": "Every MyFTB registration page: terms, profile, security questions, role, address, shared secret, phone."
|
||||
},
|
||||
{
|
||||
"id": "declaration",
|
||||
"kind": "declare",
|
||||
"statement": "perjury|i declare|under penalty",
|
||||
"why": "Ticking this box is the representative stating, under penalty of perjury, that what was entered is true. Only that person can make the statement."
|
||||
},
|
||||
{
|
||||
"id": "text-code",
|
||||
"kind": "code",
|
||||
"channel": "sms",
|
||||
"relay": ["terminal", "file"],
|
||||
"pattern": "^\\w{4,10}$",
|
||||
"timeout": "PT15M",
|
||||
"why": "FTB texts a code to the phone number given. Whoever holds the phone reads it out."
|
||||
},
|
||||
{
|
||||
"id": "pin-letter",
|
||||
"kind": "mail",
|
||||
"what": "MyFTB PIN letter",
|
||||
"arrives": "5 to 10 business days, to the address FTB has on file",
|
||||
"expires": "P21D",
|
||||
"resume": "ftb-activate-business",
|
||||
"input": "pin",
|
||||
"handoff": "pin-letter",
|
||||
"why": "FTB activates a new account with a PIN it sends by US Mail. Nobody but the addressee can read it."
|
||||
}
|
||||
],
|
||||
"submit": {
|
||||
"labels": "^(submit|continue|next|log ?in|login|activate|send( code| me a code)?|verify|confirm)$",
|
||||
"never": "^(back|cancel|end session|previous)$",
|
||||
"ignore": "#timer, .modal"
|
||||
},
|
||||
"outcomes": [
|
||||
{
|
||||
"name": "rejected",
|
||||
"kind": "rejected",
|
||||
"text": "does not match our records|there is a problem|unable to (verify|process) your|account (is|has been) locked"
|
||||
},
|
||||
{
|
||||
"name": "registered",
|
||||
"kind": "success",
|
||||
"text": "registration confirmation|successfully (registered|created)|we will (mail|send) you a (letter|pin)|pin .*(mail|letter)",
|
||||
"then": "pin-letter"
|
||||
}
|
||||
],
|
||||
"retry": { "shared_secret": "never", "page_errors": "rejected" },
|
||||
"outputs": {
|
||||
"vault": {
|
||||
"when": "registered",
|
||||
"keys": {
|
||||
"FTB_BUSINESS_USERNAME": "{{username}}",
|
||||
"FTB_BUSINESS_PASSWORD": "{{password}}",
|
||||
"FTB_BUSINESS_EMAIL": "{{email}}",
|
||||
"FTB_BUSINESS_SECURITY_ANSWERS": "{{security}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"handoffs": {
|
||||
"pin-letter": {
|
||||
"title": "FTB PIN letter: business MyFTB account",
|
||||
"open": "https://webapp.ftb.ca.gov/MyFTBAccess/",
|
||||
"steps": [
|
||||
"Watch the mail at the address FTB has on file for the MyFTB PIN letter (5 to 10 business days).",
|
||||
"Activate before {{expires_on}}: the PIN expires 21 days after registration.",
|
||||
"Run the command below yourself, with the PIN from the letter."
|
||||
],
|
||||
"command": "ftb activate business --pin <PIN from the letter>"
|
||||
}
|
||||
}
|
||||
}
|
||||
16
packages/schemas/fixtures/openerrand/index.json
Normal file
16
packages/schemas/fixtures/openerrand/index.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"type": "logicsrc.openerrand-index",
|
||||
"version": "0.1",
|
||||
"publisher": "https://logicsrc.com/.well-known/openprofile.md",
|
||||
"updated": "2026-10-04T00:00:00Z",
|
||||
"errands": [
|
||||
{
|
||||
"url": "https://logicsrc.com/examples/openerrand/ftb-register-business.json",
|
||||
"name": "ftb-register-business",
|
||||
"site": "https://webapp.ftb.ca.gov",
|
||||
"title": "Register a MyFTB business account",
|
||||
"gates": ["declare", "code", "mail"],
|
||||
"updated": "2026-10-04T00:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -23,6 +23,8 @@
|
|||
"opencontext",
|
||||
"opencreds",
|
||||
"openrental",
|
||||
"openerrand",
|
||||
"browser-automation",
|
||||
"openontology",
|
||||
"password-manager",
|
||||
"standards",
|
||||
|
|
@ -67,6 +69,8 @@
|
|||
"./opencreds-manifest": "./schemas/logicsrc-opencreds-manifest.schema.json",
|
||||
"./opencreds-vault-meta": "./schemas/logicsrc-opencreds-vault-meta.schema.json",
|
||||
"./openrental": "./schemas/logicsrc-openrental.schema.json",
|
||||
"./openerrand": "./schemas/logicsrc-openerrand.schema.json",
|
||||
"./openerrand-index": "./schemas/logicsrc-openerrand-index.schema.json",
|
||||
"./openontology-action": "./schemas/logicsrc-openontology-action.schema.json",
|
||||
"./openontology-approval": "./schemas/logicsrc-openontology-approval.schema.json",
|
||||
"./openontology-changeset": "./schemas/logicsrc-openontology-changeset.schema.json",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://schemas.logicsrc.com/logicsrc-openerrand-index.schema.json",
|
||||
"title": "OpenErrand index",
|
||||
"description": "The list of errand files a publisher serves at /.well-known/openerrand.json.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "version", "errands"],
|
||||
"properties": {
|
||||
"type": { "const": "logicsrc.openerrand-index" },
|
||||
"version": { "const": "0.1" },
|
||||
"publisher": { "type": "string", "format": "uri", "pattern": "^https://" },
|
||||
"updated": { "type": "string", "format": "date-time" },
|
||||
"errands": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["url", "site", "title"],
|
||||
"properties": {
|
||||
"url": { "type": "string", "format": "uri", "pattern": "^https://" },
|
||||
"name": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]{0,63}$" },
|
||||
"site": { "type": "string", "pattern": "^https://[a-z0-9.-]+(:[0-9]+)?$" },
|
||||
"title": { "type": "string", "minLength": 1 },
|
||||
"gates": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": { "enum": ["declare", "identity-proofing", "code", "mail", "captcha"] }
|
||||
},
|
||||
"updated": { "type": "string", "format": "date-time" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
434
packages/schemas/schemas/logicsrc-openerrand.schema.json
Normal file
434
packages/schemas/schemas/logicsrc-openerrand.schema.json
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://schemas.logicsrc.com/logicsrc-openerrand.schema.json",
|
||||
"title": "OpenErrand",
|
||||
"description": "A declarative description of one errand a person runs on a website that has no API: the site, the inputs and where each may come from, the field rules, the human gates a runner must never automate, the outcomes, the retry policy, the outputs and the hand-off cards.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "version", "name", "title", "site", "steps", "outcomes"],
|
||||
"properties": {
|
||||
"type": { "const": "logicsrc.openerrand" },
|
||||
"version": { "const": "0.1" },
|
||||
"id": { "$ref": "#/$defs/httpsUrl" },
|
||||
"name": { "$ref": "#/$defs/slug" },
|
||||
"title": { "type": "string", "minLength": 1, "maxLength": 160 },
|
||||
"description": { "type": "string" },
|
||||
"publisher": { "$ref": "#/$defs/httpsUrl" },
|
||||
"updated": { "type": "string", "format": "date-time" },
|
||||
"reference": { "$ref": "#/$defs/httpsUrl" },
|
||||
"principal": { "enum": ["self", "represented"] },
|
||||
"site": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name", "origins", "start"],
|
||||
"properties": {
|
||||
"name": { "type": "string", "minLength": 1 },
|
||||
"sector": { "enum": ["government", "tax", "financial", "healthcare", "identity-provider", "commercial", "other"] },
|
||||
"origins": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "$ref": "#/$defs/origin" } },
|
||||
"start": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/httpsUrl" } },
|
||||
"terms": { "$ref": "#/$defs/httpsUrl" }
|
||||
}
|
||||
},
|
||||
"limits": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"pages": { "type": "integer", "minimum": 1, "maximum": 200 },
|
||||
"page_timeout": { "$ref": "#/$defs/duration" },
|
||||
"same_page": { "type": "integer", "minimum": 1, "maximum": 10 }
|
||||
}
|
||||
},
|
||||
"inputs": {
|
||||
"type": "object",
|
||||
"propertyNames": { "$ref": "#/$defs/inputName" },
|
||||
"additionalProperties": { "$ref": "#/$defs/input" }
|
||||
},
|
||||
"rules": { "type": "array", "items": { "$ref": "#/$defs/rule" } },
|
||||
"steps": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/step" } },
|
||||
"submit": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["labels"],
|
||||
"properties": {
|
||||
"labels": { "$ref": "#/$defs/pattern" },
|
||||
"never": { "$ref": "#/$defs/pattern" },
|
||||
"ignore": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
},
|
||||
"outcomes": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/outcome" } },
|
||||
"retry": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"shared_secret": { "const": "never" },
|
||||
"page_errors": { "enum": ["rejected", "continue"] }
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"vault": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["keys"],
|
||||
"properties": {
|
||||
"when": { "type": "string", "minLength": 1 },
|
||||
"keys": {
|
||||
"type": "object",
|
||||
"minProperties": 1,
|
||||
"propertyNames": { "pattern": "^[A-Z][A-Z0-9_]*$" },
|
||||
"additionalProperties": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["match", "to"],
|
||||
"properties": {
|
||||
"match": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1,
|
||||
"properties": {
|
||||
"url": { "$ref": "#/$defs/pattern" },
|
||||
"filename": { "$ref": "#/$defs/pattern" },
|
||||
"type": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
},
|
||||
"to": { "type": "string", "minLength": 1 },
|
||||
"when": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"handoffs": {
|
||||
"type": "object",
|
||||
"propertyNames": { "$ref": "#/$defs/slug" },
|
||||
"additionalProperties": { "$ref": "#/$defs/handoff" }
|
||||
},
|
||||
"metadata": { "type": "object" }
|
||||
},
|
||||
"$defs": {
|
||||
"httpsUrl": { "type": "string", "format": "uri", "pattern": "^https://" },
|
||||
"origin": { "type": "string", "pattern": "^https://[a-z0-9.-]+(:[0-9]+)?$" },
|
||||
"slug": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]{0,63}$" },
|
||||
"inputName": { "type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$" },
|
||||
"pattern": { "type": "string", "minLength": 1, "format": "regex" },
|
||||
"duration": { "type": "string", "format": "duration" },
|
||||
"transform": { "type": "string", "pattern": "^(digits|whole|upper|lower|trim|first:[0-9]+|last:[0-9]+)$" },
|
||||
"fieldType": { "enum": ["text", "password", "email", "tel", "number", "date", "textarea", "select-one", "radio", "checkbox"] },
|
||||
"input": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "sensitivity", "sources"],
|
||||
"properties": {
|
||||
"label": { "type": "string", "minLength": 1 },
|
||||
"type": { "enum": ["string", "integer", "number", "email", "date", "boolean", "qa-set"] },
|
||||
"sensitivity": { "enum": ["public", "personal", "secret"] },
|
||||
"role": { "enum": ["shared-secret", "credential", "identifier"] },
|
||||
"required": { "type": "boolean" },
|
||||
"pattern": { "$ref": "#/$defs/pattern" },
|
||||
"max_length": { "type": "integer", "minimum": 1 },
|
||||
"count": { "type": "integer", "minimum": 1, "maximum": 10 },
|
||||
"sources": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/source" } }
|
||||
}
|
||||
},
|
||||
"source": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["from"],
|
||||
"properties": { "from": { "const": "prompt" }, "ask": { "type": "string", "minLength": 1 } }
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["from", "key"],
|
||||
"properties": { "from": { "const": "vault" }, "key": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]*$" } }
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["from", "form", "field"],
|
||||
"properties": {
|
||||
"from": { "const": "document" },
|
||||
"form": { "type": "string", "minLength": 1 },
|
||||
"field": { "type": "string", "minLength": 1 },
|
||||
"match": { "$ref": "#/$defs/pattern" },
|
||||
"pick": { "enum": ["newest", "oldest", "each"] },
|
||||
"years": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"back": { "type": "integer", "minimum": 1, "maximum": 50 },
|
||||
"current": { "type": "boolean" }
|
||||
}
|
||||
},
|
||||
"transform": { "$ref": "#/$defs/transform" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["from", "input", "transform"],
|
||||
"properties": {
|
||||
"from": { "const": "derive" },
|
||||
"input": { "$ref": "#/$defs/inputName" },
|
||||
"transform": { "$ref": "#/$defs/transform" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["from", "input", "part"],
|
||||
"properties": {
|
||||
"from": { "const": "candidate" },
|
||||
"input": { "$ref": "#/$defs/inputName" },
|
||||
"part": { "enum": ["year", "form", "field", "source"] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["from", "length", "classes"],
|
||||
"properties": {
|
||||
"from": { "const": "generate" },
|
||||
"length": { "type": "integer", "minimum": 1, "maximum": 256 },
|
||||
"classes": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": { "enum": ["lower", "upper", "digit", "special"] }
|
||||
},
|
||||
"special": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["from", "value"],
|
||||
"properties": { "from": { "const": "literal" }, "value": { "anyOf": [{ "type": "string" }, { "type": "number" }, { "type": "boolean" }] } }
|
||||
}
|
||||
]
|
||||
},
|
||||
"rule": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name", "do"],
|
||||
"anyOf": [{ "properties": { "id": true }, "required": ["id"] }, { "properties": { "label": true }, "required": ["label"] }],
|
||||
"properties": {
|
||||
"name": { "type": "string", "minLength": 1 },
|
||||
"id": { "$ref": "#/$defs/pattern" },
|
||||
"label": { "$ref": "#/$defs/pattern" },
|
||||
"types": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "$ref": "#/$defs/fieldType" } },
|
||||
"do": { "$ref": "#/$defs/action" }
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["text"],
|
||||
"properties": {
|
||||
"text": { "type": "string" },
|
||||
"split": { "type": "array", "minItems": 2, "items": { "type": "integer", "minimum": 1 } }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["select"],
|
||||
"properties": { "select": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/pattern" } } }
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["check"],
|
||||
"properties": {
|
||||
"check": {
|
||||
"oneOf": [
|
||||
{ "const": true },
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["label"],
|
||||
"properties": { "label": { "$ref": "#/$defs/pattern" } }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["skip"],
|
||||
"properties": { "skip": { "const": true } }
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["gate"],
|
||||
"properties": { "gate": { "$ref": "#/$defs/slug" } }
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["choose"],
|
||||
"properties": { "choose": { "$ref": "#/$defs/inputName" } }
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["answer"],
|
||||
"properties": { "answer": { "$ref": "#/$defs/inputName" } }
|
||||
}
|
||||
]
|
||||
},
|
||||
"match": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1,
|
||||
"properties": {
|
||||
"url": { "$ref": "#/$defs/pattern" },
|
||||
"title": { "$ref": "#/$defs/pattern" },
|
||||
"text": { "$ref": "#/$defs/pattern" },
|
||||
"selector": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
},
|
||||
"step": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "kind"],
|
||||
"properties": {
|
||||
"id": { "$ref": "#/$defs/slug" },
|
||||
"kind": { "const": "page" },
|
||||
"match": { "$ref": "#/$defs/match" },
|
||||
"rules": { "type": "array", "items": { "$ref": "#/$defs/rule" } },
|
||||
"unmatched": { "enum": ["stop", "ask"] },
|
||||
"say": { "type": "string" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "kind", "match", "timeout"],
|
||||
"properties": {
|
||||
"id": { "$ref": "#/$defs/slug" },
|
||||
"kind": { "const": "wait" },
|
||||
"match": { "$ref": "#/$defs/match" },
|
||||
"timeout": { "$ref": "#/$defs/duration" },
|
||||
"poll": { "$ref": "#/$defs/duration" },
|
||||
"say": { "type": "string" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "kind", "statement", "why"],
|
||||
"properties": {
|
||||
"id": { "$ref": "#/$defs/slug" },
|
||||
"kind": { "const": "declare" },
|
||||
"statement": { "$ref": "#/$defs/pattern" },
|
||||
"why": { "type": "string", "minLength": 1 },
|
||||
"say": { "type": "string" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "kind", "provider", "origins", "why"],
|
||||
"properties": {
|
||||
"id": { "$ref": "#/$defs/slug" },
|
||||
"kind": { "const": "identity-proofing" },
|
||||
"provider": { "type": "string", "minLength": 1 },
|
||||
"origins": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "$ref": "#/$defs/origin" } },
|
||||
"match": { "$ref": "#/$defs/match" },
|
||||
"timeout": { "$ref": "#/$defs/duration" },
|
||||
"handoff": { "$ref": "#/$defs/slug" },
|
||||
"why": { "type": "string", "minLength": 1 },
|
||||
"say": { "type": "string" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "kind", "channel", "relay", "timeout"],
|
||||
"properties": {
|
||||
"id": { "$ref": "#/$defs/slug" },
|
||||
"kind": { "const": "code" },
|
||||
"channel": { "enum": ["sms", "email", "voice", "app"] },
|
||||
"relay": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "enum": ["terminal", "file", "page"] } },
|
||||
"pattern": { "$ref": "#/$defs/pattern" },
|
||||
"timeout": { "$ref": "#/$defs/duration" },
|
||||
"why": { "type": "string" },
|
||||
"say": { "type": "string" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "kind", "what"],
|
||||
"properties": {
|
||||
"id": { "$ref": "#/$defs/slug" },
|
||||
"kind": { "const": "mail" },
|
||||
"what": { "type": "string", "minLength": 1 },
|
||||
"arrives": { "type": "string" },
|
||||
"expires": { "$ref": "#/$defs/duration" },
|
||||
"resume": { "$ref": "#/$defs/slug" },
|
||||
"input": { "$ref": "#/$defs/inputName" },
|
||||
"handoff": { "$ref": "#/$defs/slug" },
|
||||
"why": { "type": "string" },
|
||||
"say": { "type": "string" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "kind", "match"],
|
||||
"properties": {
|
||||
"id": { "$ref": "#/$defs/slug" },
|
||||
"kind": { "const": "captcha" },
|
||||
"match": { "$ref": "#/$defs/match" },
|
||||
"solver": { "enum": ["forbidden", "allowed"] },
|
||||
"timeout": { "$ref": "#/$defs/duration" },
|
||||
"why": { "type": "string" },
|
||||
"say": { "type": "string" }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"outcome": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name", "kind"],
|
||||
"anyOf": [{ "properties": { "text": true }, "required": ["text"] }, { "properties": { "url": true }, "required": ["url"] }],
|
||||
"properties": {
|
||||
"name": { "$ref": "#/$defs/slug" },
|
||||
"kind": { "enum": ["success", "rejected", "waiting"] },
|
||||
"text": { "$ref": "#/$defs/pattern" },
|
||||
"url": { "$ref": "#/$defs/pattern" },
|
||||
"then": { "$ref": "#/$defs/slug" }
|
||||
}
|
||||
},
|
||||
"handoff": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["title", "steps"],
|
||||
"properties": {
|
||||
"title": { "type": "string", "minLength": 1 },
|
||||
"open": { "$ref": "#/$defs/httpsUrl" },
|
||||
"steps": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } },
|
||||
"command": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@
|
|||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"test": "vitest run src",
|
||||
"validate:fixtures": "node dist/cli.js task ../schemas/fixtures/task.yaml && node dist/cli.js agent ../schemas/fixtures/agent.yaml && node dist/cli.js agentad-ad ../schemas/fixtures/agentad-ad.yaml && node dist/cli.js agentad-placement ../schemas/fixtures/agentad-placement.yaml && node dist/cli.js repo ../schemas/fixtures/repo.yaml && node dist/cli.js pull-request ../schemas/fixtures/pull-request.yaml && node dist/cli.js openontology-manifest ../schemas/fixtures/openontology/valid/manifest.json && node dist/cli.js openontology-claim ../schemas/fixtures/openontology/valid/claim-relationship.json && node dist/cli.js openontology-changeset ../schemas/fixtures/openontology/valid/changeset.json && node dist/cli.js opencontext-manifest ../schemas/fixtures/opencontext/valid/manifest.json && node dist/cli.js opencontext-object ../schemas/fixtures/opencontext/valid/object-policy.json && node dist/cli.js opencontext-bundle ../schemas/fixtures/opencontext/valid/bundle.json && node dist/cli.js opencontext-decision ../schemas/fixtures/opencontext/valid/decision.json && node dist/cli.js opencontext-role ../schemas/fixtures/opencontext/valid/role.json && node dist/cli.js opencontext-provenance ../schemas/fixtures/opencontext/valid/provenance.json && node dist/cli.js opencontext-diagnostic ../schemas/fixtures/opencontext/valid/diagnostic.json && node dist/cli.js opencontext-audit-event ../schemas/fixtures/opencontext/valid/audit-event.json && node dist/cli.js openrental ../schemas/fixtures/openrental/mixed.json && node dist/cli.js openwall-message ../schemas/fixtures/openwall/broadcast.json && node dist/cli.js openwall-message ../schemas/fixtures/openwall/direct.json && node dist/cli.js openwall-message ../schemas/fixtures/openwall/announcement.json && node dist/cli.js openwall-receipt ../schemas/fixtures/openwall/receipt-accepted.json && node dist/cli.js openwall-receipt ../schemas/fixtures/openwall/receipt-retrying.json && node dist/cli.js openabtest-manifest ../schemas/fixtures/openabtest/chovy-manifest.json && node dist/cli.js openabtest-event ../schemas/fixtures/openabtest/reconciliation.json"
|
||||
"validate:fixtures": "node dist/cli.js task ../schemas/fixtures/task.yaml && node dist/cli.js agent ../schemas/fixtures/agent.yaml && node dist/cli.js agentad-ad ../schemas/fixtures/agentad-ad.yaml && node dist/cli.js agentad-placement ../schemas/fixtures/agentad-placement.yaml && node dist/cli.js repo ../schemas/fixtures/repo.yaml && node dist/cli.js pull-request ../schemas/fixtures/pull-request.yaml && node dist/cli.js openontology-manifest ../schemas/fixtures/openontology/valid/manifest.json && node dist/cli.js openontology-claim ../schemas/fixtures/openontology/valid/claim-relationship.json && node dist/cli.js openontology-changeset ../schemas/fixtures/openontology/valid/changeset.json && node dist/cli.js opencontext-manifest ../schemas/fixtures/opencontext/valid/manifest.json && node dist/cli.js opencontext-object ../schemas/fixtures/opencontext/valid/object-policy.json && node dist/cli.js opencontext-bundle ../schemas/fixtures/opencontext/valid/bundle.json && node dist/cli.js opencontext-decision ../schemas/fixtures/opencontext/valid/decision.json && node dist/cli.js opencontext-role ../schemas/fixtures/opencontext/valid/role.json && node dist/cli.js opencontext-provenance ../schemas/fixtures/opencontext/valid/provenance.json && node dist/cli.js opencontext-diagnostic ../schemas/fixtures/opencontext/valid/diagnostic.json && node dist/cli.js opencontext-audit-event ../schemas/fixtures/opencontext/valid/audit-event.json && node dist/cli.js openrental ../schemas/fixtures/openrental/mixed.json && node dist/cli.js openerrand ../schemas/fixtures/openerrand/ftb-register-business.json && node dist/cli.js openerrand-index ../schemas/fixtures/openerrand/index.json && node dist/cli.js openwall-message ../schemas/fixtures/openwall/broadcast.json && node dist/cli.js openwall-message ../schemas/fixtures/openwall/direct.json && node dist/cli.js openwall-message ../schemas/fixtures/openwall/announcement.json && node dist/cli.js openwall-receipt ../schemas/fixtures/openwall/receipt-accepted.json && node dist/cli.js openwall-receipt ../schemas/fixtures/openwall/receipt-retrying.json && node dist/cli.js openabtest-manifest ../schemas/fixtures/openabtest/chovy-manifest.json && node dist/cli.js openabtest-event ../schemas/fixtures/openabtest/reconciliation.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@logicsrc/schemas": "^0.3.0",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { parse } from "yaml";
|
|||
import { isSchemaKind, schemas, type SchemaKind } from "./schemas.js";
|
||||
import { validateOpenABTest } from "./openabtest.js";
|
||||
import { validateOpenRentalReferences } from "./openrental.js";
|
||||
import { validateOpenErrandReferences } from "./openerrand.js";
|
||||
|
||||
type CompiledSchema = { (data: unknown): boolean; errors?: ErrorObject[] | null };
|
||||
|
||||
|
|
@ -67,6 +68,10 @@ export function validate(kind: SchemaKind, data: unknown): ValidationResult {
|
|||
const errors = validateOpenRentalReferences(data);
|
||||
if (errors.length) return { ok: false, kind, errors };
|
||||
}
|
||||
if (kind === "openerrand") {
|
||||
const errors = validateOpenErrandReferences(data);
|
||||
if (errors.length) return { ok: false, kind, errors };
|
||||
}
|
||||
if (kind === "openabtest-manifest" || kind === "openabtest-event") {
|
||||
const errors = validateOpenABTest(kind, data);
|
||||
if (errors.length) return { ok: false, kind, errors };
|
||||
|
|
|
|||
102
packages/validators/src/openerrand.test.ts
Normal file
102
packages/validators/src/openerrand.test.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { assertSchemaKind, validate } from "./index.js";
|
||||
|
||||
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
|
||||
const fixture = () => JSON.parse(read("../../schemas/fixtures/openerrand/ftb-register-business.json"));
|
||||
const doc = read("../../../docs/openerrand.md");
|
||||
|
||||
/** Every ```json block in docs/openerrand.md, in order. */
|
||||
const blocks = [...doc.matchAll(/```json\n([\s\S]*?)\n```/g)].map((m) => m[1]!);
|
||||
|
||||
function errorAt(data: unknown, keyword: string, path: string) {
|
||||
const result = validate("openerrand", data);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.errors).toContainEqual(expect.objectContaining({ keyword, instancePath: path }));
|
||||
}
|
||||
|
||||
describe("OpenErrand", () => {
|
||||
it("registers both exported schemas and validates the fixtures", () => {
|
||||
expect(assertSchemaKind("openerrand")).toBe("openerrand");
|
||||
expect(assertSchemaKind("openerrand-index")).toBe("openerrand-index");
|
||||
expect(validate("openerrand", fixture())).toMatchObject({ ok: true });
|
||||
expect(validate("openerrand-index", JSON.parse(read("../../schemas/fixtures/openerrand/index.json")))).toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it("validates the smallest errand, the worked example and the index printed in the specification", () => {
|
||||
const [smallest, , , index, example] = blocks.map((b) => JSON.parse(b));
|
||||
expect(validate("openerrand", smallest)).toMatchObject({ ok: true });
|
||||
expect(example).toEqual(fixture());
|
||||
expect(validate("openerrand-index", index)).toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it("puts no personal or secret value in the published example", () => {
|
||||
// The repository is public: the example names fields and sources, never a person's data.
|
||||
const text = JSON.stringify(fixture());
|
||||
expect(text).not.toMatch(/\b\d{3}-?\d{2}-?\d{4}\b/);
|
||||
expect(text).not.toMatch(/Jane|Doe|Maple|1234567|48210/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an unknown key", (f: any) => { f.solver = "2captcha"; }, "additionalProperties", ""],
|
||||
["an input with no sensitivity", (f: any) => { delete f.inputs.email.sensitivity; }, "required", "/inputs/email"],
|
||||
["a shared secret that may be retried", (f: any) => { f.retry.shared_secret = "once"; }, "const", "/retry/shared_secret"],
|
||||
["a gate step without its why", (f: any) => { delete f.steps[2].why; }, "oneOf", "/steps/2"],
|
||||
["a plain http origin", (f: any) => { f.site.origins = ["http://webapp.ftb.ca.gov"]; }, "pattern", "/site/origins/0"]
|
||||
])("rejects %s", (_, mutate, keyword, path) => {
|
||||
const f = fixture();
|
||||
mutate(f);
|
||||
errorAt(f, keyword, path);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a personal input on a hand-off card", (f: any) => { f.handoffs["pin-letter"].steps.push("Mail it to {{street}}"); }, "handoffSensitivity", "/handoffs/pin-letter/steps/3"],
|
||||
["a secret input in a card's command", (f: any) => { f.handoffs["pin-letter"].command = "ftb activate --password {{password}}"; }, "handoffSensitivity", "/handoffs/pin-letter/command"],
|
||||
["a template naming no input", (f: any) => { f.rules[2].do.text = "{{given_name}}"; }, "templateReference", "/rules/2/do/text"],
|
||||
["a gate action pointing at a page step", (f: any) => { f.rules[25].do.gate = "form"; }, "gateReference", "/rules/25/do/gate"],
|
||||
["a shared secret that is only personal", (f: any) => { f.inputs.net_income.sensitivity = "personal"; }, "sharedSecretSensitivity", "/inputs/net_income/sensitivity"],
|
||||
["a choose action on a plain input", (f: any) => { f.rules[14].do.choose = "email"; }, "qaSetReference", "/rules/14/do/choose"],
|
||||
["an outcome that follows a missing step", (f: any) => { f.outcomes[1].then = "pin-postcard"; }, "stepReference", "/outcomes/1/then"],
|
||||
["a mail gate with a missing card", (f: any) => { f.steps[4].handoff = "postcard"; }, "handoffReference", "/steps/4/handoff"],
|
||||
["duplicate step ids", (f: any) => { f.steps[1].id = "bot-check"; }, "uniqueStep", "/steps/1/id"]
|
||||
])("rejects %s", (_, mutate, keyword, path) => {
|
||||
const f = fixture();
|
||||
mutate(f);
|
||||
errorAt(f, keyword, path);
|
||||
});
|
||||
|
||||
const commercial = () => ({
|
||||
...JSON.parse(blocks[0]!),
|
||||
site: { name: "Example", sector: "commercial", origins: ["https://example.com"], start: ["https://example.com/contact"] },
|
||||
steps: [{ id: "form", kind: "page" }, { id: "robot-check", kind: "captcha", match: { selector: "iframe[src*=captcha]" }, solver: "allowed" }]
|
||||
});
|
||||
|
||||
it("allows a declared captcha solver on a commercial errand with nothing sensitive", () => {
|
||||
expect(validate("openerrand", commercial())).toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a solver on a tax site", (f: any) => { f.steps.push({ id: "robot-check", kind: "captcha", match: { selector: "iframe" }, solver: "allowed" }); }, "/steps/5/solver"],
|
||||
["a solver on an errand that does not state its sector", (f: any) => { f.steps.push({ id: "robot-check", kind: "captcha", match: { selector: "iframe" }, solver: "allowed" }); delete f.site.sector; }, "/steps/5/solver"]
|
||||
])("rejects %s", (_, mutate, path) => {
|
||||
const f = fixture();
|
||||
mutate(f);
|
||||
errorAt(f, "captchaSolver", path);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a secret input", (f: any) => { f.inputs = { password: { type: "string", sensitivity: "secret", sources: [{ from: "prompt" }] } }; }],
|
||||
["a declare step", (f: any) => { f.steps.push({ id: "attest", kind: "declare", statement: "i declare", why: "Yours to say." }); }],
|
||||
["a government site", (f: any) => { f.site.sector = "government"; }]
|
||||
])("rejects a captcha solver on a commercial errand with %s", (_, mutate) => {
|
||||
const f = commercial();
|
||||
mutate(f);
|
||||
errorAt(f, "captchaSolver", "/steps/1/solver");
|
||||
});
|
||||
|
||||
it("allows public inputs and built-ins on a card", () => {
|
||||
const f = fixture();
|
||||
f.handoffs["pin-letter"].steps.push("Corporation {{corp_id}} at {{site.name}}: {{errand.title}}");
|
||||
expect(validate("openerrand", f)).toMatchObject({ ok: true });
|
||||
});
|
||||
});
|
||||
159
packages/validators/src/openerrand.ts
Normal file
159
packages/validators/src/openerrand.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import type { ErrorObject } from "ajv";
|
||||
|
||||
// Called only after the JSON Schema has checked the shape. These are the
|
||||
// OpenErrand rules that need sibling values: every reference resolves, every
|
||||
// template names an input that exists, and nothing personal or secret can be
|
||||
// rendered onto a hand-off card.
|
||||
|
||||
type Source = { from: string; input?: string };
|
||||
type Input = { type: string; sensitivity: "public" | "personal" | "secret"; role?: string; sources: Source[] };
|
||||
type Action = { text?: string; gate?: string; choose?: string; answer?: string };
|
||||
type Rule = { do: Action };
|
||||
type Step = { id: string; kind: string; rules?: Rule[]; handoff?: string; resume?: string; solver?: string };
|
||||
type Errand = {
|
||||
site: { sector?: string };
|
||||
inputs?: Record<string, Input>;
|
||||
rules?: Rule[];
|
||||
steps: Step[];
|
||||
outcomes: Array<{ name: string; then?: string }>;
|
||||
outputs?: { vault?: { when?: string; keys: Record<string, string> }; downloads?: Array<{ to: string; when?: string }> };
|
||||
handoffs?: Record<string, { title: string; steps: string[]; command?: string }>;
|
||||
};
|
||||
|
||||
/** Step kinds a runner must hand to a person. */
|
||||
export const GATE_KINDS = ["declare", "identity-proofing", "code", "mail", "captcha"] as const;
|
||||
|
||||
/** Sectors where a captcha solver is never allowed. */
|
||||
export const NO_SOLVER_SECTORS = ["government", "tax", "financial", "healthcare", "identity-provider"] as const;
|
||||
|
||||
/** Names a hand-off card may use: none of them is a person's data. */
|
||||
export const HANDOFF_BUILTINS = ["expires_on", "errand.title", "site.name"] as const;
|
||||
|
||||
const TEMPLATE = /\{\{\s*([a-z][a-z0-9_.]*)\s*\}\}/g;
|
||||
|
||||
export function templateNames(text: string): string[] {
|
||||
return [...text.matchAll(TEMPLATE)].map((m) => m[1]!);
|
||||
}
|
||||
|
||||
export function validateOpenErrandReferences(data: unknown): ErrorObject[] {
|
||||
const errand = data as Errand;
|
||||
const errors: ErrorObject[] = [];
|
||||
function report(keyword: string, instancePath: string, message: string) {
|
||||
errors.push({ keyword, instancePath, schemaPath: "#/openerrand-semantics", params: {}, message });
|
||||
}
|
||||
|
||||
const inputs = errand.inputs ?? {};
|
||||
const steps = new Map<string, Step>();
|
||||
errand.steps.forEach((step, i) => {
|
||||
if (steps.has(step.id)) report("uniqueStep", `/steps/${i}/id`, "duplicates an existing step id");
|
||||
steps.set(step.id, step);
|
||||
});
|
||||
const outcomes = new Set<string>();
|
||||
errand.outcomes.forEach((outcome, i) => {
|
||||
if (outcomes.has(outcome.name)) report("uniqueOutcome", `/outcomes/${i}/name`, "duplicates an existing outcome name");
|
||||
outcomes.add(outcome.name);
|
||||
});
|
||||
const handoffs = errand.handoffs ?? {};
|
||||
|
||||
for (const [name, input] of Object.entries(inputs)) {
|
||||
const path = `/inputs/${name}`;
|
||||
if (input.role === "shared-secret" && input.sensitivity !== "secret") {
|
||||
report("sharedSecretSensitivity", `${path}/sensitivity`, "a shared secret is always sensitivity secret");
|
||||
}
|
||||
if (input.type === "qa-set" && input.sensitivity === "public") {
|
||||
report("qaSetSensitivity", `${path}/sensitivity`, "security answers are never public");
|
||||
}
|
||||
input.sources.forEach((source, j) => {
|
||||
if ((source.from === "derive" || source.from === "candidate") && (!source.input || !(source.input in inputs) || source.input === name)) {
|
||||
report("inputReference", `${path}/sources/${j}/input`, "must name another input of this errand");
|
||||
}
|
||||
if (source.from === "candidate" && source.input && inputs[source.input]?.role !== "shared-secret") {
|
||||
report("candidateReference", `${path}/sources/${j}/input`, "a candidate part comes from a shared-secret input");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function checkTemplate(text: string, path: string) {
|
||||
for (const name of templateNames(text)) {
|
||||
if (!(name in inputs)) report("templateReference", path, `{{${name}}} is not an input of this errand`);
|
||||
}
|
||||
}
|
||||
|
||||
function checkRules(rules: Rule[] | undefined, base: string) {
|
||||
rules?.forEach((rule, i) => {
|
||||
const path = `${base}/${i}/do`;
|
||||
const action = rule.do;
|
||||
if (action.text !== undefined) checkTemplate(action.text, `${path}/text`);
|
||||
if (action.gate !== undefined) {
|
||||
const target = steps.get(action.gate);
|
||||
if (!target || !(GATE_KINDS as readonly string[]).includes(target.kind)) {
|
||||
report("gateReference", `${path}/gate`, "must name a declare, identity-proofing, code, mail or captcha step");
|
||||
}
|
||||
}
|
||||
for (const key of ["choose", "answer"] as const) {
|
||||
const name = action[key];
|
||||
if (name !== undefined && inputs[name]?.type !== "qa-set") {
|
||||
report("qaSetReference", `${path}/${key}`, "must name a qa-set input");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
checkRules(errand.rules, "/rules");
|
||||
errand.steps.forEach((step, i) => checkRules(step.rules, `/steps/${i}/rules`));
|
||||
|
||||
errand.steps.forEach((step, i) => {
|
||||
if (step.handoff !== undefined && !(step.handoff in handoffs)) {
|
||||
report("handoffReference", `/steps/${i}/handoff`, "must name a hand-off card of this errand");
|
||||
}
|
||||
});
|
||||
errand.outcomes.forEach((outcome, i) => {
|
||||
if (outcome.then !== undefined && !steps.has(outcome.then)) {
|
||||
report("stepReference", `/outcomes/${i}/then`, "must name a step of this errand");
|
||||
}
|
||||
});
|
||||
|
||||
const vault = errand.outputs?.vault;
|
||||
if (vault) {
|
||||
if (vault.when !== undefined && !outcomes.has(vault.when)) report("outcomeReference", "/outputs/vault/when", "must name an outcome");
|
||||
for (const [key, value] of Object.entries(vault.keys)) checkTemplate(value, `/outputs/vault/keys/${key}`);
|
||||
}
|
||||
errand.outputs?.downloads?.forEach((download, i) => {
|
||||
if (download.when !== undefined && !outcomes.has(download.when)) report("outcomeReference", `/outputs/downloads/${i}/when`, "must name an outcome");
|
||||
checkTemplate(download.to, `/outputs/downloads/${i}/to`);
|
||||
});
|
||||
|
||||
// A captcha solver is allowed only where nothing sensitive is at stake: a
|
||||
// stated sector outside the forbidden set, no attestation, no identity
|
||||
// proofing and no secret input.
|
||||
const sensitive =
|
||||
errand.steps.some((step) => step.kind === "declare" || step.kind === "identity-proofing") ||
|
||||
Object.values(inputs).some((input) => input.sensitivity === "secret");
|
||||
const sector = errand.site?.sector;
|
||||
errand.steps.forEach((step, i) => {
|
||||
if (step.kind !== "captcha" || step.solver !== "allowed") return;
|
||||
const path = `/steps/${i}/solver`;
|
||||
if (!sector) report("captchaSolver", path, "an errand that allows a captcha solver states its site.sector");
|
||||
else if ((NO_SOLVER_SECTORS as readonly string[]).includes(sector)) report("captchaSolver", path, `a captcha solver is never allowed on a ${sector} site`);
|
||||
if (sensitive) report("captchaSolver", path, "a captcha solver is never allowed on an errand with a declare or identity-proofing step or a secret input");
|
||||
});
|
||||
|
||||
// A hand-off card is shared on purpose: it may name built-ins and public
|
||||
// inputs, and nothing a person would not post on a fridge.
|
||||
for (const [id, card] of Object.entries(handoffs)) {
|
||||
const texts: Array<[string, string]> = [
|
||||
[`/handoffs/${id}/title`, card.title],
|
||||
...card.steps.map((step, i): [string, string] => [`/handoffs/${id}/steps/${i}`, step]),
|
||||
...(card.command !== undefined ? [[`/handoffs/${id}/command`, card.command] as [string, string]] : [])
|
||||
];
|
||||
for (const [path, text] of texts) {
|
||||
for (const name of templateNames(text)) {
|
||||
if ((HANDOFF_BUILTINS as readonly string[]).includes(name)) continue;
|
||||
const input = inputs[name];
|
||||
if (!input) report("templateReference", path, `{{${name}}} is not an input or a hand-off built-in`);
|
||||
else if (input.sensitivity !== "public") report("handoffSensitivity", path, `{{${name}}} is ${input.sensitivity} and never goes on a hand-off card`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
|
@ -11,6 +11,8 @@ import openabtestEventSchema from "@logicsrc/schemas/openabtest-event" with { ty
|
|||
*/
|
||||
import agentSchema from "@logicsrc/schemas/agent" with { type: "json" };
|
||||
import openrentalSchema from "@logicsrc/schemas/openrental" with { type: "json" };
|
||||
import openerrandSchema from "@logicsrc/schemas/openerrand" with { type: "json" };
|
||||
import openerrandIndexSchema from "@logicsrc/schemas/openerrand-index" with { type: "json" };
|
||||
import accountAuditEventSchema from "@logicsrc/schemas/account-audit-event" with { type: "json" };
|
||||
import accountGrantSchema from "@logicsrc/schemas/account-grant" with { type: "json" };
|
||||
import accountProviderSchema from "@logicsrc/schemas/account-provider" with { type: "json" };
|
||||
|
|
@ -81,6 +83,8 @@ export const schemas = {
|
|||
"openwall-receipt": openwallReceiptSchema,
|
||||
agent: agentSchema,
|
||||
openrental: openrentalSchema,
|
||||
openerrand: openerrandSchema,
|
||||
"openerrand-index": openerrandIndexSchema,
|
||||
"account-audit-event": accountAuditEventSchema,
|
||||
"account-grant": accountGrantSchema,
|
||||
"account-provider": accountProviderSchema,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue