@clossys/butler 0.1.1
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.
- package/CHANGELOG.md +85 -0
- package/LICENSE +21 -0
- package/README.md +260 -0
- package/dist/audit-shape.check.d.ts +32 -0
- package/dist/audit-shape.check.d.ts.map +1 -0
- package/dist/audit-shape.check.js +7 -0
- package/dist/audit-shape.check.js.map +1 -0
- package/dist/cli.d.ts +54 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +426 -0
- package/dist/cli.js.map +1 -0
- package/dist/contract.d.ts +256 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +377 -0
- package/dist/contract.js.map +1 -0
- package/dist/inbound/index.d.ts +120 -0
- package/dist/inbound/index.d.ts.map +1 -0
- package/dist/inbound/index.js +125 -0
- package/dist/inbound/index.js.map +1 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +47 -0
- package/dist/index.js.map +1 -0
- package/dist/schema.d.ts +374 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +304 -0
- package/dist/schema.js.map +1 -0
- package/dist/validation.d.ts +74 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +140 -0
- package/dist/validation.js.map +1 -0
- package/dist/web/index.d.ts +5 -0
- package/dist/web/index.d.ts.map +1 -0
- package/dist/web/index.js +25 -0
- package/dist/web/index.js.map +1 -0
- package/dist/web/internal/peer-version.d.ts +53 -0
- package/dist/web/internal/peer-version.d.ts.map +1 -0
- package/dist/web/internal/peer-version.js +136 -0
- package/dist/web/internal/peer-version.js.map +1 -0
- package/dist/web/useStandingWants.d.ts +75 -0
- package/dist/web/useStandingWants.d.ts.map +1 -0
- package/dist/web/useStandingWants.js +66 -0
- package/dist/web/useStandingWants.js.map +1 -0
- package/package.json +93 -0
- package/src/audit-shape.check.ts +37 -0
- package/src/cli.ts +445 -0
- package/src/contract.ts +534 -0
- package/src/inbound/index.ts +190 -0
- package/src/index.ts +113 -0
- package/src/schema.ts +622 -0
- package/src/validation.ts +172 -0
- package/src/web/index.ts +27 -0
- package/src/web/internal/peer-version.ts +159 -0
- package/src/web/useStandingWants.ts +139 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound admission doctrine — deliberately NOT an HTTP handler.
|
|
3
|
+
*
|
|
4
|
+
* This is the front door of the butler role: a request arrives on some
|
|
5
|
+
* channel and has to be admitted before anything can be interpreted into
|
|
6
|
+
* an intent. Admission is the only question answered here. Interpretation,
|
|
7
|
+
* the confidence it carries, and the read-back that follows all live in
|
|
8
|
+
* the root export (`../schema.js`, `../contract.js`); nothing in this file
|
|
9
|
+
* reads a request's content, and no field here can hold one.
|
|
10
|
+
*
|
|
11
|
+
* The ownership split is the same one the storage and audit ports use at
|
|
12
|
+
* the root: the host implements a ledger interface and owns the transport;
|
|
13
|
+
* this package owns the decision logic on top of it.
|
|
14
|
+
*
|
|
15
|
+
* - The consumer owns the HTTP route, raw-body access, and SIGNATURE
|
|
16
|
+
* VERIFICATION. Signature schemes are provider-specific — this package
|
|
17
|
+
* cannot test a provider's signing algorithm against a real secret and
|
|
18
|
+
* must not pretend to verify what it cannot exercise.
|
|
19
|
+
* - This package owns the ADMISSION DECISION: dedupe, ack/reject doctrine,
|
|
20
|
+
* replay tolerance, and ordering tolerance, all as a pure function of the
|
|
21
|
+
* caller's own verification result plus a ledger's dedupe answer.
|
|
22
|
+
*
|
|
23
|
+
* Zero runtime dependencies, matching the rest of this package.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The host implements durable, atomic dedupe against its own storage.
|
|
28
|
+
* Inbound events are at-least-once and may be delivered more than once and out of order, so
|
|
29
|
+
* `recordIfNew` must be a single atomic check-and-record operation — not a
|
|
30
|
+
* separate existence check followed by a later insert, or two concurrent
|
|
31
|
+
* deliveries of the same event can both observe `"new"`.
|
|
32
|
+
*/
|
|
33
|
+
export interface InboundEventLedger {
|
|
34
|
+
recordIfNew(event: { readonly provider: string; readonly eventId: string }): Promise<"new" | "duplicate">;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* One inbound provider webhook event, prior to any admission decision.
|
|
39
|
+
*
|
|
40
|
+
* `signature` is the result of the caller's OWN signature verification —
|
|
41
|
+
* there is no default and no third option that means "not checked yet".
|
|
42
|
+
* An unverified event is not representable by omission: leaving the field
|
|
43
|
+
* out is a type error, and any runtime value other than the literal
|
|
44
|
+
* `"verified"` is treated as not verified.
|
|
45
|
+
*/
|
|
46
|
+
export interface InboundAdmissionInput {
|
|
47
|
+
/** The provider's name for this event source (e.g. "resend"). Scopes dedupe together with `eventId`. */
|
|
48
|
+
provider: string;
|
|
49
|
+
/** Unique within the provider; required for dedupe. */
|
|
50
|
+
eventId: string;
|
|
51
|
+
/** The provider-reported event timestamp, as an ISO-8601 (or otherwise `Date`-parseable) string. */
|
|
52
|
+
occurredAt: string;
|
|
53
|
+
/** The result of the CALLER's own signature verification. No default. */
|
|
54
|
+
signature: "verified" | "invalid";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Why a durably-accepted event was not handed off for processing. */
|
|
58
|
+
export type InboundAdmissionIgnoreReason =
|
|
59
|
+
| { kind: "duplicate" }
|
|
60
|
+
| { kind: "malformed"; field: string; message: string };
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The admission decision. Never a bare boolean — a caller must be able to
|
|
64
|
+
* see and act on ack vs. process as two separate questions.
|
|
65
|
+
*
|
|
66
|
+
* Doctrine encoded here:
|
|
67
|
+
* - **Ack on durable acceptance, not on successful processing.** A
|
|
68
|
+
* processing failure downstream of `action: "process"` is not a reason to
|
|
69
|
+
* have withheld the ack; it already happened.
|
|
70
|
+
* - **Reject only on signature failure.** `ack: false` is reserved for
|
|
71
|
+
* `reason: "signature-invalid"` — every other rejection of work is
|
|
72
|
+
* expressed as `ack: true, action: "ignore"` so a provider is never told
|
|
73
|
+
* to keep retrying data that will never become processable.
|
|
74
|
+
* - **A replay is an ack with `action: "ignore"`, never an error.**
|
|
75
|
+
*/
|
|
76
|
+
export type InboundAdmissionDecision =
|
|
77
|
+
| { ack: true; action: "process" }
|
|
78
|
+
| { ack: true; action: "ignore"; reason: InboundAdmissionIgnoreReason }
|
|
79
|
+
| { ack: false; reason: "signature-invalid" };
|
|
80
|
+
|
|
81
|
+
function isNonEmptyString(value: unknown): value is string {
|
|
82
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isParseableTimestamp(value: unknown): boolean {
|
|
86
|
+
return typeof value === "string" && value.trim().length > 0 && !Number.isNaN(Date.parse(value));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Validate an admission input independently of dedupe. Returns either
|
|
91
|
+
* `{ valid: true }` — proceed to the ledger — or `{ valid: false, decision
|
|
92
|
+
* }` with the exact terminal decision to return without ever consulting a
|
|
93
|
+
* ledger.
|
|
94
|
+
*
|
|
95
|
+
* Order matters: signature is checked first. An unverified caller's claims
|
|
96
|
+
* about `eventId`, `provider`, or `occurredAt` are not trustworthy input,
|
|
97
|
+
* so nothing past the signature check runs until it passes.
|
|
98
|
+
*/
|
|
99
|
+
function validateInboundAdmissionInput(
|
|
100
|
+
input: InboundAdmissionInput,
|
|
101
|
+
): { valid: true } | { valid: false; decision: InboundAdmissionDecision } {
|
|
102
|
+
// Anything other than the exact literal "verified" — including "invalid",
|
|
103
|
+
// an unrecognized string, undefined, or any other malformed value — fails
|
|
104
|
+
// closed the same way. There is no default and no way to express "not yet
|
|
105
|
+
// checked" that reaches processing.
|
|
106
|
+
if (input?.signature !== "verified") {
|
|
107
|
+
return { valid: false, decision: { ack: false, reason: "signature-invalid" } };
|
|
108
|
+
}
|
|
109
|
+
if (!isNonEmptyString(input.provider)) {
|
|
110
|
+
return {
|
|
111
|
+
valid: false,
|
|
112
|
+
decision: {
|
|
113
|
+
ack: true,
|
|
114
|
+
action: "ignore",
|
|
115
|
+
reason: { kind: "malformed", field: "provider", message: "must be a non-empty string" },
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
if (!isNonEmptyString(input.eventId)) {
|
|
120
|
+
return {
|
|
121
|
+
valid: false,
|
|
122
|
+
decision: {
|
|
123
|
+
ack: true,
|
|
124
|
+
action: "ignore",
|
|
125
|
+
reason: { kind: "malformed", field: "eventId", message: "must be a non-empty string" },
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
if (!isParseableTimestamp(input.occurredAt)) {
|
|
130
|
+
return {
|
|
131
|
+
valid: false,
|
|
132
|
+
decision: {
|
|
133
|
+
ack: true,
|
|
134
|
+
action: "ignore",
|
|
135
|
+
reason: { kind: "malformed", field: "occurredAt", message: "must be a parseable timestamp" },
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
return { valid: true };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The pure decision core, kept separate from the ledger round-trip so it is
|
|
144
|
+
* directly testable with a plain `"new" | "duplicate"` value instead of a
|
|
145
|
+
* mock ledger. `admitInboundEvent` below is the only caller that needs an
|
|
146
|
+
* actual `InboundEventLedger`.
|
|
147
|
+
*/
|
|
148
|
+
export function decideInboundAdmission(
|
|
149
|
+
input: InboundAdmissionInput,
|
|
150
|
+
dedupe: "new" | "duplicate",
|
|
151
|
+
): InboundAdmissionDecision {
|
|
152
|
+
const validation = validateInboundAdmissionInput(input);
|
|
153
|
+
if (!validation.valid) return validation.decision;
|
|
154
|
+
if (dedupe === "duplicate") {
|
|
155
|
+
return { ack: true, action: "ignore", reason: { kind: "duplicate" } };
|
|
156
|
+
}
|
|
157
|
+
return { ack: true, action: "process" };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Decide whether an inbound provider webhook event should be acknowledged
|
|
162
|
+
* and, if so, whether it should be processed.
|
|
163
|
+
*
|
|
164
|
+
* This function does no I/O of its own beyond calling `ledger.recordIfNew`
|
|
165
|
+
* once, and only after `input` has already passed structural and signature
|
|
166
|
+
* validation — a malformed or unverified event never reaches the ledger.
|
|
167
|
+
*
|
|
168
|
+
* No input combination yields `{ ack: true, action: "process" }` unless
|
|
169
|
+
* `input.signature === "verified"` AND the ledger reports the event as new.
|
|
170
|
+
*
|
|
171
|
+
* A THROWING LEDGER REJECTS THIS PROMISE, AND THAT IS THE CORRECT DECLINE
|
|
172
|
+
* PATH — do not catch it and ack. This is the same three-state discipline
|
|
173
|
+
* the gates keep at the other end of the package: "could not check" is
|
|
174
|
+
* never reported as "checked and fine". Inbound does not own anything
|
|
175
|
+
* yet: if durable dedupe could not be performed, this function
|
|
176
|
+
* cannot know whether the event is a replay, so acking it would silently
|
|
177
|
+
* discard an event that may never have been processed. Rejecting lets the
|
|
178
|
+
* caller's route return a 5xx and the provider redeliver, which is exactly
|
|
179
|
+
* what at-least-once delivery is for. Ack means "durably accepted"; an
|
|
180
|
+
* unreachable ledger means nothing was durably accepted.
|
|
181
|
+
*/
|
|
182
|
+
export async function admitInboundEvent(
|
|
183
|
+
input: InboundAdmissionInput,
|
|
184
|
+
ledger: InboundEventLedger,
|
|
185
|
+
): Promise<InboundAdmissionDecision> {
|
|
186
|
+
const validation = validateInboundAdmissionInput(input);
|
|
187
|
+
if (!validation.valid) return validation.decision;
|
|
188
|
+
const dedupe = await ledger.recordIfNew({ provider: input.provider, eventId: input.eventId });
|
|
189
|
+
return decideInboundAdmission(input, dedupe);
|
|
190
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @clossys/butler — everything about what a person wants, now and
|
|
3
|
+
* standing.
|
|
4
|
+
*
|
|
5
|
+
* The question this role answers, and no other role does: **do we have what
|
|
6
|
+
* this person wants — this request in their own confirmation, and their
|
|
7
|
+
* standing instructions, still current?**
|
|
8
|
+
*
|
|
9
|
+
* Three halves ship here, and the third is what justifies the first two:
|
|
10
|
+
*
|
|
11
|
+
* 1. THE SCHEMA (`schema.ts`). Hand-rolled, dependency-free validators
|
|
12
|
+
* over the two record families: `IntentRecord` and `ConfirmationRecord`
|
|
13
|
+
* for one request read back to the person who made it, and
|
|
14
|
+
* `StandingInstruction` for the durable answers that keep speaking
|
|
15
|
+
* afterwards. Consent is three states — `absent`, `denied`, `granted`
|
|
16
|
+
* — never a boolean, so absence can never read as permission. Storage
|
|
17
|
+
* and audit are host-supplied ports (`StandingInstructionStore`,
|
|
18
|
+
* `StandingAuditLedger`); no implementation of either ships here.
|
|
19
|
+
*
|
|
20
|
+
* 2. THE EVALUATION (`contract.ts`). `evaluateStandingInstruction`
|
|
21
|
+
* compares one stored answer against the policy in force AND the
|
|
22
|
+
* clock, adding `stale` as a fourth EVALUATION status that is never a
|
|
23
|
+
* stored state. `decideStandingChange` is the pure decision core for
|
|
24
|
+
* one change, and `recordReopened`/`recordStaleness` build the audit
|
|
25
|
+
* events a host chooses to record.
|
|
26
|
+
*
|
|
27
|
+
* 3. THE GATES. Three checkers, all reachable from the single
|
|
28
|
+
* `butler-check` bin: `checkConfirmationCompleteness`,
|
|
29
|
+
* `checkCurrency`, and `checkWithdrawalParity`. Each is a pure
|
|
30
|
+
* function returning a three-state result, and `cli.ts` folds those
|
|
31
|
+
* onto the `0`/`1`/`2` exit contract without ever collapsing "could
|
|
32
|
+
* not run" into either "clean" or "findings".
|
|
33
|
+
*
|
|
34
|
+
* Two subpaths sit beside this one. `./inbound` is admission — whether an
|
|
35
|
+
* event arriving on a channel should be acknowledged and processed at all,
|
|
36
|
+
* decided as a pure function of the caller's own signature verification and
|
|
37
|
+
* a host ledger's dedupe answer. `./web` is preference-surface state, and
|
|
38
|
+
* is the only entry point that touches React.
|
|
39
|
+
*
|
|
40
|
+
* Nothing in this package's own source is a real topic vocabulary, a real
|
|
41
|
+
* confidence floor, a real currency window, a jurisdiction rule, or an
|
|
42
|
+
* obligation. It makes no claim of legal compliance. Ships the schema and
|
|
43
|
+
* the checkers; every consumer authors its own values.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
export {
|
|
47
|
+
CONFIRMATION_VERDICTS,
|
|
48
|
+
INTENT_DISPOSITIONS,
|
|
49
|
+
STANDING_AUDIT_EVENT_TYPES,
|
|
50
|
+
STANDING_PROVENANCES,
|
|
51
|
+
isConfirmationRecord,
|
|
52
|
+
isIntentRecord,
|
|
53
|
+
isStandingInstruction,
|
|
54
|
+
validateConfidenceFloor,
|
|
55
|
+
validateConfirmationRecord,
|
|
56
|
+
validateConfirmationRecords,
|
|
57
|
+
validateInstructionUsages,
|
|
58
|
+
validateIntentRecord,
|
|
59
|
+
validateIntentRecords,
|
|
60
|
+
validatePolicyVersion,
|
|
61
|
+
validatePreferencePaths,
|
|
62
|
+
validateStandingInstruction,
|
|
63
|
+
validateStandingInstructions,
|
|
64
|
+
} from "./schema.js";
|
|
65
|
+
export type {
|
|
66
|
+
ConfidenceFloor,
|
|
67
|
+
ConfirmationRecord,
|
|
68
|
+
ConfirmationVerdict,
|
|
69
|
+
CurrencyWindow,
|
|
70
|
+
InstructionUsage,
|
|
71
|
+
IntentDisposition,
|
|
72
|
+
IntentRecord,
|
|
73
|
+
PathCost,
|
|
74
|
+
PolicyVersion,
|
|
75
|
+
PreferencePath,
|
|
76
|
+
StandingAction,
|
|
77
|
+
StandingAuditEvent,
|
|
78
|
+
StandingAuditEventType,
|
|
79
|
+
StandingAuditLedger,
|
|
80
|
+
StandingEvaluation,
|
|
81
|
+
StandingEvaluationPolicy,
|
|
82
|
+
StandingInstruction,
|
|
83
|
+
StandingInstructionStore,
|
|
84
|
+
StandingProvenance,
|
|
85
|
+
StandingState,
|
|
86
|
+
StandingTopic,
|
|
87
|
+
} from "./schema.js";
|
|
88
|
+
|
|
89
|
+
export {
|
|
90
|
+
checkConfirmationCompleteness,
|
|
91
|
+
checkCurrency,
|
|
92
|
+
checkWithdrawalParity,
|
|
93
|
+
decideStandingChange,
|
|
94
|
+
evaluateStandingInstruction,
|
|
95
|
+
recordReopened,
|
|
96
|
+
recordStaleness,
|
|
97
|
+
} from "./contract.js";
|
|
98
|
+
export type {
|
|
99
|
+
ConfirmationCompletenessResult,
|
|
100
|
+
ConfirmationFailureReason,
|
|
101
|
+
ConfirmationFinding,
|
|
102
|
+
ConfirmationFindingKind,
|
|
103
|
+
CurrencyFailureReason,
|
|
104
|
+
CurrencyFinding,
|
|
105
|
+
CurrencyFindingKind,
|
|
106
|
+
CurrencyResult,
|
|
107
|
+
WithdrawalParityFailureReason,
|
|
108
|
+
WithdrawalParityFinding,
|
|
109
|
+
WithdrawalParityFindingKind,
|
|
110
|
+
WithdrawalParityResult,
|
|
111
|
+
} from "./contract.js";
|
|
112
|
+
|
|
113
|
+
export type { ValidationIssue, ValidationResult, Validator } from "./validation.js";
|