From af2c22a03df6e8f9e85fef15a63bad34b9a0a203 Mon Sep 17 00:00:00 2001 From: William Neeley Date: Thu, 30 Apr 2026 11:38:37 -0700 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20@orangecheck/me-client=20v0.3.0=20?= =?UTF-8?q?=E2=80=94=20math=20invariant=20cleanup,=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the pressure-test improvements in oc-me-web: - computeFees() refactored to `gross × user_share_pct` form (clearer semantics, identical numeric result for valid inputs) - validateIntegratorConfig(cfg) → ValidationResult helper for pre-flight checking against MIN_INTEGRATOR_PRICE_SATS, user_share_pct ∈ [0, 0.8], percent ∈ (0, 1] - 13 mirror tests covering math invariants and validation cases Already published; this commit makes the diff auditable. Co-Authored-By: Claude Opus 4.7 (1M context) --- me-client/package.json | 2 +- me-client/src/__tests__/types.test.ts | 170 ++++++++++++++++++++++- me-client/src/index.ts | 12 ++ me-client/src/types.ts | 188 ++++++++++++++++++++++---- 4 files changed, 342 insertions(+), 30 deletions(-) diff --git a/me-client/package.json b/me-client/package.json index b27da52..1349f36 100644 --- a/me-client/package.json +++ b/me-client/package.json @@ -1,6 +1,6 @@ { "name": "@orangecheck/me-client", - "version": "0.2.0", + "version": "0.3.0", "description": "Drop-in client for me.ochk.io. Sign-in-with-OC button, session lifecycle hooks (oc.session.create / refresh / invalidate), payment authorization, and the canonical billable-event taxonomy as types.", "keywords": [ "orangecheck", diff --git a/me-client/src/__tests__/types.test.ts b/me-client/src/__tests__/types.test.ts index dca0d84..47c84e1 100644 --- a/me-client/src/__tests__/types.test.ts +++ b/me-client/src/__tests__/types.test.ts @@ -1,14 +1,25 @@ import { describe, it, expect } from 'vitest'; -import type { BillableEvent, EventClass, SessionPolicy } from '../types'; +import { + MIN_INTEGRATOR_PRICE_SATS, + PLATFORM_FEE_POLICY, + computeFees, + validateIntegratorConfig, +} from '../types'; +import type { + BillableEvent, + EventClass, + IntegratorEventConfig, + SessionPolicy, +} from '../types'; -describe('@orangecheck/me-client types', () => { +describe('@orangecheck/me-client · types', () => { it('EventClass is exactly A | B | C', () => { const classes: EventClass[] = ['A', 'B', 'C']; expect(classes).toHaveLength(3); }); - it('BillableEvent shape matches Addendum 01 contract', () => { + it('BillableEvent shape includes site_rebate_sats', () => { const sample: BillableEvent = { id: 'oc-me-7a3c9e2f', occurred_at: '2026-04-30T16:08:42Z', @@ -16,11 +27,14 @@ describe('@orangecheck/me-client types', () => { subtype: 'payment_authorization', site: { domain: 'breez.example', display_name: 'Breez' }, gross_fee_sats: 1280, - platform_fee_sats: 448, + platform_fee_sats: 256, user_earned_sats: 832, + site_rebate_sats: 192, verify_url: 'https://me.ochk.io/verify/oc-me-7a3c9e2f', }; - expect(sample.gross_fee_sats - sample.platform_fee_sats).toBe(sample.user_earned_sats); + expect( + sample.platform_fee_sats + sample.user_earned_sats + sample.site_rebate_sats + ).toBe(sample.gross_fee_sats); }); it('SessionPolicy refresh modes are constrained', () => { @@ -34,3 +48,149 @@ describe('@orangecheck/me-client types', () => { expect([banking, saas, mobile]).toHaveLength(3); }); }); + +describe('@orangecheck/me-client · computeFees invariants', () => { + const cases: { name: string; cfg: IntegratorEventConfig; amt?: number }[] = [ + { + name: 'fixed price typical', + cfg: { + enabled: true, + site_pays: { kind: 'fixed_sats', sats: 1300 }, + user_share_pct: 0.65, + }, + }, + { + name: 'fixed price clamped to floor', + cfg: { + enabled: true, + site_pays: { kind: 'fixed_sats', sats: 1 }, + user_share_pct: 0.65, + }, + }, + { + name: 'percent on real payment', + cfg: { + enabled: true, + site_pays: { kind: 'percent_of_amount', pct: 0.0075 }, + user_share_pct: 0.65, + }, + amt: 240_000, + }, + { + name: 'user_share = 0 → site keeps full rebate', + cfg: { + enabled: true, + site_pays: { kind: 'fixed_sats', sats: 1000 }, + user_share_pct: 0, + }, + }, + { + name: 'user_share = 0.8 → no rebate', + cfg: { + enabled: true, + site_pays: { kind: 'fixed_sats', sats: 1000 }, + user_share_pct: 0.8, + }, + }, + ]; + + for (const c of cases) { + it(`${c.name} — gross == platform + user + rebate`, () => { + const f = computeFees(c.cfg, c.amt); + expect(f.platform_fee_sats + f.user_earned_sats + f.site_rebate_sats).toBe( + f.gross_fee_sats + ); + expect(f.gross_fee_sats).toBeGreaterThanOrEqual(MIN_INTEGRATOR_PRICE_SATS); + expect(f.platform_fee_sats).toBeGreaterThanOrEqual( + PLATFORM_FEE_POLICY.min_floor_sats + ); + expect(f.user_earned_sats).toBeGreaterThanOrEqual(0); + expect(f.site_rebate_sats).toBeGreaterThanOrEqual(0); + }); + } + + it('user_share > 0.8 is clamped, no negative rebate', () => { + const f = computeFees({ + enabled: true, + site_pays: { kind: 'fixed_sats', sats: 1000 }, + user_share_pct: 0.95, + }); + expect(f.user_earned_sats).toBe(800); + expect(f.site_rebate_sats).toBe(0); + }); + + it('percent_of_amount throws when amount missing', () => { + expect(() => + computeFees({ + enabled: true, + site_pays: { kind: 'percent_of_amount', pct: 0.005 }, + user_share_pct: 0.5, + }) + ).toThrow(); + }); +}); + +describe('@orangecheck/me-client · validateIntegratorConfig', () => { + it('rejects sats below MIN_INTEGRATOR_PRICE_SATS for an enabled event', () => { + const r = validateIntegratorConfig({ + project_key: 'pk', + display_name: 'X', + domain: 'x.example', + updated_at: '', + events: { + account_creation: { + enabled: true, + site_pays: { kind: 'fixed_sats', sats: 2 }, + user_share_pct: 0.5, + }, + }, + }); + expect(r.ok).toBe(false); + expect(r.errors.some((e) => e.subtype === 'account_creation')).toBe(true); + }); + + it('rejects user_share_pct > 0.8', () => { + const r = validateIntegratorConfig({ + project_key: 'pk', + display_name: 'X', + domain: 'x.example', + updated_at: '', + events: { + session_creation: { + enabled: true, + site_pays: { kind: 'fixed_sats', sats: 100 }, + user_share_pct: 0.95, + }, + }, + }); + expect(r.ok).toBe(false); + }); + + it('accepts a fully-formed config', () => { + const r = validateIntegratorConfig({ + project_key: 'pk_live_yourcompany', + display_name: 'YourCompany', + domain: 'yourcompany.example', + updated_at: '2026-04-30T00:00:00Z', + events: { + account_creation: { + enabled: true, + site_pays: { kind: 'fixed_sats', sats: 1300 }, + user_share_pct: 0.65, + }, + payment_authorization: { + enabled: true, + site_pays: { kind: 'percent_of_amount', pct: 0.0075 }, + user_share_pct: 0.65, + }, + session_creation: { + enabled: true, + site_pays: { kind: 'fixed_sats', sats: 55 }, + user_share_pct: 0.65, + }, + }, + }); + expect(r.errors).toEqual([]); + expect(r.ok).toBe(true); + }); +}); diff --git a/me-client/src/index.ts b/me-client/src/index.ts index 6dedb88..90d596b 100644 --- a/me-client/src/index.ts +++ b/me-client/src/index.ts @@ -16,6 +16,13 @@ export { session, onTelemetry } from './session'; export { payment } from './payment'; export { setOrigin, getOrigin, MeClientError } from './transport'; +export { + PLATFORM_FEE_POLICY, + MIN_INTEGRATOR_PRICE_SATS, + computeFees, + validateIntegratorConfig, +} from './types'; + export type { EventClass, EventSubtype, @@ -23,6 +30,11 @@ export type { ClassBSubtype, ClassCSubtype, AttestTier, + SiteFeeShape, + IntegratorEventConfig, + IntegratorPriceConfig, + ComputedFees, + ValidationResult, BillableEvent, Session, SessionPolicy, diff --git a/me-client/src/types.ts b/me-client/src/types.ts index 3588a2a..aff7323 100644 --- a/me-client/src/types.ts +++ b/me-client/src/types.ts @@ -1,14 +1,36 @@ /** - * Canonical billable-event taxonomy for me.ochk.io. + * Canonical types for me.ochk.io integrators. * - * Mirrors the source of truth at oc-me-web/src/lib/events/types.ts. Per - * Addendum 01: every chargeable event is in exactly one of three classes. - * Sites pay for sessions and actions, not for clicks. Free events - * (intra-session signin, refresh, failed/cancelled, navigation, passive - * verify) never instantiate a BillableEvent — they hit the developer - * telemetry stream but not the billing system. + * Mirrors the source of truth at oc-me-web/src/lib/events/types.ts. Two + * layers: + * - Platform-level (PLATFORM_FEE_POLICY, MIN_INTEGRATOR_PRICE_SATS) — + * fixed by OC, surfaced as constants here so integrators can validate + * their config client-side before posting it. + * - Integrator-level (IntegratorPriceConfig) — every integrator declares + * their own per-event prices and user-share splits. + * + * Per Addendum 01: every chargeable event is in exactly one of three + * classes. Sites pay for sessions and actions, not for clicks. Free + * events never instantiate a BillableEvent. */ +// ── platform constants (fixed by OC) ────────────────────────────────────── + +export const PLATFORM_FEE_POLICY = { + /** Fraction of every gross_fee that OC retains. */ + pct: 0.2, + /** Absolute minimum platform fee, in sats. */ + min_floor_sats: 1, + /** Date this policy was last ratified. Changes require an oc-me-protocol PR. */ + ratified: '2026-04-30', +} as const; + +/** Anti-spam price floor. No integrator can configure a per-event price + * below this. */ +export const MIN_INTEGRATOR_PRICE_SATS = 5; + +// ── event taxonomy ──────────────────────────────────────────────────────── + export type EventClass = 'A' | 'B' | 'C'; export type ClassASubtype = @@ -32,6 +54,138 @@ export type EventSubtype = ClassASubtype | ClassBSubtype | ClassCSubtype; export type AttestTier = 'anonymous' | 'bonded' | 'kyc_light' | 'kyc_strong'; +/** The shape of a per-event fee — either a fixed sats amount or a percent + * of the underlying transaction amount (used for payments + pledges). */ +export type SiteFeeShape = + | { kind: 'fixed_sats'; sats: number } + | { kind: 'percent_of_amount'; pct: number }; + +/** What an integrator declares for a single event subtype. */ +export interface IntegratorEventConfig { + /** Whether this site bills this event subtype at all. */ + enabled: boolean; + /** The per-event fee the site pays. */ + site_pays: SiteFeeShape; + /** Fraction of post-platform-fee that flows to the user (0–0.8 since + * platform_fee is 20%). The remainder is a rebate to the site's OC + * project balance. */ + user_share_pct: number; +} + +/** A complete integrator pricing config. Posted to me.ochk.io once at + * integration time, mutable via the configurator UI or this SDK any time + * after. */ +export interface IntegratorPriceConfig { + project_key: string; + display_name: string; + domain: string; + /** ISO-8601 timestamp the config was last updated. */ + updated_at: string; + /** Per-subtype configs. Subtypes not listed are disabled by default. */ + events: Partial>; +} + +/** A computed fee breakdown for a single event. site_rebate_sats is what + * flows back to the integrator's OC balance after OC's platform fee and + * the user's cashback. */ +export interface ComputedFees { + gross_fee_sats: number; + platform_fee_sats: number; + user_earned_sats: number; + site_rebate_sats: number; +} + +/** Compute the four-way fee split for an event given an integrator's + * config and (for percent_of_amount events) the underlying payment + * amount. + * + * Invariants (verified by tests in oc-me-web/src/lib/events/types.test.ts): + * 1. gross_fee_sats >= MIN_INTEGRATOR_PRICE_SATS + * 2. platform_fee_sats >= PLATFORM_FEE_POLICY.min_floor_sats + * 3. user_share_pct is clamped to [0, 0.8] + * 4. site_rebate_sats >= 0 + * 5. gross == platform + user + rebate (exact, modulo rounding into rebate) + */ +export function computeFees( + cfg: IntegratorEventConfig, + payment_amount_sats?: number +): ComputedFees { + let gross = 0; + if (cfg.site_pays.kind === 'fixed_sats') { + gross = Math.max(MIN_INTEGRATOR_PRICE_SATS, Math.round(cfg.site_pays.sats)); + } else { + if (payment_amount_sats == null) { + throw new Error('percent_of_amount config requires payment_amount_sats'); + } + gross = Math.max( + MIN_INTEGRATOR_PRICE_SATS, + Math.round(payment_amount_sats * cfg.site_pays.pct) + ); + } + const platform_fee_sats = Math.max( + PLATFORM_FEE_POLICY.min_floor_sats, + Math.round(gross * PLATFORM_FEE_POLICY.pct) + ); + const user_share = Math.min(0.8, Math.max(0, cfg.user_share_pct)); + const user_earned_sats = Math.round(gross * user_share); + const site_rebate_sats = Math.max(0, gross - platform_fee_sats - user_earned_sats); + return { gross_fee_sats: gross, platform_fee_sats, user_earned_sats, site_rebate_sats }; +} + +/** Result of validating an IntegratorPriceConfig. */ +export interface ValidationResult { + ok: boolean; + errors: { subtype?: EventSubtype; message: string }[]; +} + +/** + * Validate an integrator's pricing config against the platform's + * non-negotiable rules. Run client-side before posting; the server runs + * the same checks and rejects anything that fails. + */ +export function validateIntegratorConfig(cfg: IntegratorPriceConfig): ValidationResult { + const errors: ValidationResult['errors'] = []; + + if (!cfg.project_key) errors.push({ message: 'project_key is required' }); + if (!cfg.display_name) errors.push({ message: 'display_name is required' }); + if (!cfg.domain) errors.push({ message: 'domain is required' }); + + for (const [key, eventCfg] of Object.entries(cfg.events)) { + if (!eventCfg) continue; + const subtype = key as EventSubtype; + if (!eventCfg.enabled) continue; + + if (eventCfg.site_pays.kind === 'fixed_sats') { + if (eventCfg.site_pays.sats < MIN_INTEGRATOR_PRICE_SATS) { + errors.push({ + subtype, + message: `site_pays.sats (${eventCfg.site_pays.sats}) is below MIN_INTEGRATOR_PRICE_SATS (${MIN_INTEGRATOR_PRICE_SATS})`, + }); + } + } else { + const pct = eventCfg.site_pays.pct; + if (!(pct > 0 && pct <= 1)) { + errors.push({ + subtype, + message: `site_pays.pct (${pct}) must be in (0, 1]`, + }); + } + } + + const u = eventCfg.user_share_pct; + if (u < 0 || u > 0.8) { + errors.push({ + subtype, + message: `user_share_pct (${u}) must be in [0, 0.8]; max is 0.8 because platform_fee is ${PLATFORM_FEE_POLICY.pct}`, + }); + } + } + + return { ok: errors.length === 0, errors }; +} + +// ── canonical envelope ──────────────────────────────────────────────────── + export interface BillableEvent { id: string; occurred_at: string; @@ -41,25 +195,20 @@ export interface BillableEvent { gross_fee_sats: number; platform_fee_sats: number; user_earned_sats: number; + site_rebate_sats: number; verify_url: string; } +// ── session lifecycle ──────────────────────────────────────────────────── + export interface SessionPolicy { - /** Duration of a single session window in seconds. Site declares this at - * integration time; the OC verifier enforces it. */ duration_seconds: number; - /** How tokens refresh inside the window. `sliding` extends on activity, - * `rolling` rotates on a fixed cadence, `none` is fixed-window. */ refresh: 'sliding' | 'rolling' | 'none'; - /** What the site requires for high-stakes intra-session actions. - * `re-auth` re-opens the OC consent flow (a fresh Class C billable - * event); `none` accepts the session as-is. */ sensitive_actions?: 're-auth' | 'none'; } export interface Session { id: string; - /** The OC identity (Bitcoin address) the session is bound to. */ identity: string; opens_at: string; expires_at: string; @@ -70,21 +219,14 @@ export interface Session { export interface SignInOptions { scope: string[]; sessionPolicy?: Partial; - /** Where to return after the OC consent flow. Defaults to the current - * page's origin + path. */ returnTo?: string; } export interface PaymentAuthorizeOptions { - /** OC identity (Bitcoin address) of the signed-in user. */ identity: string; - /** Amount in sats. Use this OR usd_cents, not both. */ amount_sats?: number; - /** Amount in USD cents. Use this OR amount_sats, not both. */ usd_cents?: number; - /** Free-text description shown to the user during consent. */ description: string; - /** Optional integrator-side reference id propagated into the envelope. */ external_ref?: string; } @@ -93,12 +235,10 @@ export interface PaymentResult { status: 'authorized' | 'failed' | 'cancelled'; sats_charged?: number; user_envelope_id?: string; - /** URL that the user can visit to see this event on their /me/earn. */ verify_url?: string; } export interface TelemetryEvent { - /** Non-billable telemetry codes per oc-me-web's NON_BILLABLE_EVENTS. */ code: | 'session.intra_signin' | 'session.token_refresh' From 378a3a04f3e6c354b980bae4e6ce33f6a9eb89e7 Mon Sep 17 00:00:00 2001 From: Xaxis Date: Thu, 30 Apr 2026 12:46:16 -0700 Subject: [PATCH 2/5] =?UTF-8?q?me-client=200.4.0=20=C2=B7=20config=20+=20w?= =?UTF-8?q?ebhook=20+=20delegation=20+=20useOcSession?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Re-export OcSessionProvider / useOcSession / useOptionalOcSession from @orangecheck/auth-client so integrators pull every auth surface from one package. - oc.config.{get,update,validate} — read/write IntegratorPriceConfig. Client-side validate() runs the same validator as the server before the round-trip; update() throws on local invalidity. - oc.webhook.verify(rawBody, sigHex, kid, jwk?) — verifies OC's Ed25519 webhook signatures using @noble/curves. Accepts hex signatures (the OC-Signature header) and base64url JWK x. Caches ochk.io/.well-known/jwks.json for 1h when no JWK is passed. - oc.delegation.{issue,revoke} — agent-delegation lifecycle. v1 stub; same shape as production envelopes. @noble/curves and @noble/hashes added as direct deps (~20 KB combined). Co-Authored-By: Claude Opus 4.7 (1M context) --- me-client/package.json | 8 +- me-client/src/config.ts | 45 +++++++++++ me-client/src/delegation.ts | 61 ++++++++++++++ me-client/src/index.ts | 28 ++++++- me-client/src/webhook.ts | 154 ++++++++++++++++++++++++++++++++++++ me-client/yarn.lock | 12 +++ 6 files changed, 302 insertions(+), 6 deletions(-) create mode 100644 me-client/src/config.ts create mode 100644 me-client/src/delegation.ts create mode 100644 me-client/src/webhook.ts diff --git a/me-client/package.json b/me-client/package.json index 1349f36..a7e1ddf 100644 --- a/me-client/package.json +++ b/me-client/package.json @@ -1,7 +1,7 @@ { "name": "@orangecheck/me-client", - "version": "0.3.0", - "description": "Drop-in client for me.ochk.io. Sign-in-with-OC button, session lifecycle hooks (oc.session.create / refresh / invalidate), payment authorization, and the canonical billable-event taxonomy as types.", + "version": "0.4.0", + "description": "Drop-in client for me.ochk.io. Sign-in-with-OC button, useOcSession, oc.session/payment/config/delegation/webhook, the canonical billable-event taxonomy as types.", "keywords": [ "orangecheck", "bitcoin", @@ -45,6 +45,10 @@ "clean": "rm -rf dist", "prepublishOnly": "npm run clean && npm run build" }, + "dependencies": { + "@noble/curves": "^1.6.0", + "@noble/hashes": "^1.5.0" + }, "peerDependencies": { "@orangecheck/auth-client": "^0.4.0", "react": "^18.0.0 || ^19.0.0", diff --git a/me-client/src/config.ts b/me-client/src/config.ts new file mode 100644 index 0000000..3d468c5 --- /dev/null +++ b/me-client/src/config.ts @@ -0,0 +1,45 @@ +import type { IntegratorPriceConfig, ValidationResult } from './types'; + +import { validateIntegratorConfig } from './types'; +import { api } from './transport'; + +/** + * Read the integrator's currently-stored IntegratorPriceConfig from + * me.ochk.io. The endpoint reads from the federation index in production; + * in v1 it returns the project's last-stored config from the in-memory + * store keyed on the authenticated project_key. + */ +async function get(): Promise { + return api('/api/developer/config', { method: 'GET' }); +} + +/** + * Persist a new IntegratorPriceConfig. Validates client-side first so the + * caller gets a structured error before a round trip; the server runs the + * same validator and rejects with 422 + a list of offending subtypes if + * anything slipped past. + * + * Throws an Error containing the validation report if the config is + * invalid client-side. Throws MeClientError on server rejection. + */ +async function update(cfg: IntegratorPriceConfig): Promise { + const result = validateIntegratorConfig(cfg); + if (!result.ok) { + const summary = result.errors + .map((e) => (e.subtype ? `${e.subtype}: ${e.message}` : e.message)) + .join('; '); + throw new Error(`IntegratorPriceConfig invalid · ${summary}`); + } + return api('/api/developer/config', { + method: 'POST', + body: cfg, + }); +} + +/** Run the validator without making a network call. Useful for live + * feedback in a dashboard form. */ +function validate(cfg: IntegratorPriceConfig): ValidationResult { + return validateIntegratorConfig(cfg); +} + +export const config = { get, update, validate }; diff --git a/me-client/src/delegation.ts b/me-client/src/delegation.ts new file mode 100644 index 0000000..1576ec9 --- /dev/null +++ b/me-client/src/delegation.ts @@ -0,0 +1,61 @@ +import { api } from './transport'; + +/** A scope token an agent delegation can carry. */ +export type DelegationScope = + | 'identity.read' + | 'inbox.read' + | 'attest.verify' + | 'stamp.sign' + | 'payment.authorize'; + +export interface IssueDelegationOptions { + /** Agent identifier — usually the agent's own pubkey or DID. */ + agent_id: string; + /** Scopes the agent is allowed to act on. */ + scope: DelegationScope[]; + /** ISO-8601 expiration timestamp. Max 30 days in the future. */ + expires_at: string; + /** Optional human-readable reason shown in /me/agents. */ + reason?: string; +} + +export interface DelegationEnvelope { + /** Content-addressed envelope id. */ + id: string; + /** Scope tokens the agent is allowed. */ + scope: DelegationScope[]; + /** ISO-8601 expiration. */ + expires_at: string; + /** ISO-8601 issuance time. */ + issued_at: string; + /** Public verifier URL. */ + verify_url: string; +} + +/** + * Issue an agent delegation. Class A billable event for the integrating + * site that initiates it (the user authorizes from /me/agents). + * + * v1: returns a stub envelope until the federation signing service ships. + * The shape is canonical and matches what production will return. + */ +async function issue(opts: IssueDelegationOptions): Promise { + if (!opts.agent_id) throw new Error('agent_id is required'); + if (!opts.scope?.length) throw new Error('scope must contain at least one token'); + if (!opts.expires_at) throw new Error('expires_at is required'); + return api('/api/delegation/issue', { + method: 'POST', + body: opts, + }); +} + +/** Revoke a previously-issued delegation by envelope id. Verifier rejects + * any envelope signed under the delegation after revocation. */ +async function revoke(delegation_id: string): Promise<{ ok: true }> { + return api<{ ok: true }>('/api/delegation/revoke', { + method: 'POST', + body: { delegation_id }, + }); +} + +export const delegation = { issue, revoke }; diff --git a/me-client/src/index.ts b/me-client/src/index.ts index 90d596b..e9a756d 100644 --- a/me-client/src/index.ts +++ b/me-client/src/index.ts @@ -3,8 +3,9 @@ * * Drop-in client for me.ochk.io. Exports the canonical billable-event * taxonomy as TypeScript types, session lifecycle hooks, payment - * authorization, the developer telemetry stream, and the React sign-in - * button. + * authorization, integrator-config CRUD, agent-delegation issue/revoke, + * webhook signature verification, the developer telemetry stream, and + * the React sign-in button + `useOcSession` hook. * * Sign-in-with-OC. You pay for sessions and actions, not for clicks. */ @@ -12,8 +13,23 @@ export { OcSignInButton } from './SignInButton'; export type { OcSignInButtonProps } from './SignInButton'; +// Re-export the React session primitives so integrators can pull every +// auth surface from a single package. +export { OcSessionProvider, useOcSession, useOptionalOcSession } from '@orangecheck/auth-client'; +export type { + OcAccount, + OcSessionState, + OcSessionStatus, + OcAuthConfig, +} from '@orangecheck/auth-client'; + export { session, onTelemetry } from './session'; export { payment } from './payment'; +export { config } from './config'; +export { webhook } from './webhook'; +export type { OcPublicJwk, VerifyResult } from './webhook'; +export { delegation } from './delegation'; +export type { DelegationScope, DelegationEnvelope, IssueDelegationOptions } from './delegation'; export { setOrigin, getOrigin, MeClientError } from './transport'; export { @@ -46,7 +62,11 @@ export type { import { session } from './session'; import { payment } from './payment'; +import { config } from './config'; +import { webhook } from './webhook'; +import { delegation } from './delegation'; /** Convenience namespace mirroring the public API surface in /integrate - * code samples — `oc.session.create()`, `oc.payment.authorize()`. */ -export const oc = { session, payment }; + * code samples — `oc.session.create()`, `oc.payment.authorize()`, + * `oc.config.update()`, `oc.webhook.verify()`, `oc.delegation.issue()`. */ +export const oc = { session, payment, config, webhook, delegation }; diff --git a/me-client/src/webhook.ts b/me-client/src/webhook.ts new file mode 100644 index 0000000..53b2945 --- /dev/null +++ b/me-client/src/webhook.ts @@ -0,0 +1,154 @@ +import { ed25519 } from '@noble/curves/ed25519'; +import { sha256 } from '@noble/hashes/sha256'; + +/** + * Public Ed25519 JWK published at ochk.io/.well-known/jwks.json. Webhook + * verification uses the key matching the OC-Key-Id header. + */ +export interface OcPublicJwk { + kty: 'OKP'; + crv: 'Ed25519'; + x: string; + kid: string; + alg?: string; + use?: string; +} + +export interface VerifyResult { + ok: boolean; + /** Reason verification failed. Undefined when ok is true. */ + reason?: string; + /** kid that was matched (or attempted). */ + key_id?: string; +} + +const FAMILY_JWKS_URL = 'https://ochk.io/.well-known/jwks.json'; + +let cachedJwks: { fetched_at: number; keys: OcPublicJwk[] } | null = null; +const JWKS_TTL_MS = 60 * 60 * 1000; + +/** Fetch (and short-cache) the OC public JWKS. */ +async function fetchJwks(): Promise { + if (cachedJwks && Date.now() - cachedJwks.fetched_at < JWKS_TTL_MS) { + return cachedJwks.keys; + } + const res = await fetch(FAMILY_JWKS_URL, { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + if (!res.ok) { + throw new Error(`failed to fetch JWKS: ${res.status} ${res.statusText}`); + } + const data = (await res.json()) as { keys: OcPublicJwk[] }; + cachedJwks = { fetched_at: Date.now(), keys: data.keys }; + return data.keys; +} + +function base64urlDecode(input: string): Uint8Array { + const padded = input.replace(/-/g, '+').replace(/_/g, '/').padEnd( + Math.ceil(input.length / 4) * 4, + '=' + ); + const bin = + typeof atob === 'function' + ? atob(padded) + : Buffer.from(padded, 'base64').toString('binary'); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +function hexDecode(input: string): Uint8Array { + const clean = input.toLowerCase().replace(/^0x/, ''); + if (clean.length % 2 !== 0) throw new Error('hex string has odd length'); + const out = new Uint8Array(clean.length / 2); + for (let i = 0; i < out.length; i++) { + out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16); + } + return out; +} + +/** + * Verify a webhook signature against the raw request body. The signature + * is hex-encoded in the OC-Signature header; the kid is in OC-Key-Id. + * + * NOTE: pass the *raw* body bytes (not the parsed JSON). Frameworks that + * re-serialize before your handler runs produce a different byte sequence + * and verification will fail. In Express set + * app.use(express.text({ type: '*\/*' })); + * In Next.js Pages API routes set + * export const config = { api: { bodyParser: false } }; + * + * If `jwk` is omitted the function fetches and caches OC's published JWKS + * for an hour. Pre-fetching once on boot and passing the matching JWK + * here is the recommended hot path. + */ +export async function verify( + rawBody: string | Uint8Array, + sigHex: string, + kid: string, + jwk?: OcPublicJwk +): Promise { + let key = jwk; + if (!key || key.kid !== kid) { + const keys = await fetchJwks(); + key = keys.find((k) => k.kid === kid); + if (!key) { + return { ok: false, reason: `no JWK with kid=${kid} in published JWKS`, key_id: kid }; + } + } + if (key.kty !== 'OKP' || key.crv !== 'Ed25519') { + return { ok: false, reason: `JWK kid=${kid} is not Ed25519`, key_id: kid }; + } + + let pubKey: Uint8Array; + try { + pubKey = base64urlDecode(key.x); + } catch (err) { + return { + ok: false, + reason: `failed to decode JWK x: ${(err as Error).message}`, + key_id: kid, + }; + } + if (pubKey.length !== 32) { + return { + ok: false, + reason: `Ed25519 public key must be 32 bytes; got ${pubKey.length}`, + key_id: kid, + }; + } + + let sig: Uint8Array; + try { + sig = hexDecode(sigHex); + } catch (err) { + return { + ok: false, + reason: `failed to decode signature: ${(err as Error).message}`, + key_id: kid, + }; + } + if (sig.length !== 64) { + return { + ok: false, + reason: `Ed25519 signature must be 64 bytes; got ${sig.length}`, + key_id: kid, + }; + } + + const bodyBytes = + typeof rawBody === 'string' ? new TextEncoder().encode(rawBody) : rawBody; + const hash = sha256(bodyBytes); + + try { + const ok = ed25519.verify(sig, hash, pubKey); + return ok + ? { ok: true, key_id: kid } + : { ok: false, reason: 'signature does not match', key_id: kid }; + } catch (err) { + return { ok: false, reason: (err as Error).message, key_id: kid }; + } +} + +export const webhook = { verify, fetchJwks }; diff --git a/me-client/yarn.lock b/me-client/yarn.lock index 4f9de86..f0f260c 100644 --- a/me-client/yarn.lock +++ b/me-client/yarn.lock @@ -240,6 +240,18 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@noble/curves@^1.6.0": + version "1.9.7" + resolved "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz#79d04b4758a43e4bca2cbdc62e7771352fa6b951" + integrity sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw== + dependencies: + "@noble/hashes" "1.8.0" + +"@noble/hashes@1.8.0", "@noble/hashes@^1.5.0": + version "1.8.0" + resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz#cee43d801fcef9644b11b8194857695acd5f815a" + integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== + "@orangecheck/auth-client@^0.4.0": version "0.4.2" resolved "https://registry.npmjs.org/@orangecheck/auth-client/-/auth-client-0.4.2.tgz#ed3a3af20b755affd395c15a61d9a61ace92894b" From 1ef87221cb6f8a89dddc71be8fc74871c6904bf5 Mon Sep 17 00:00:00 2001 From: Xaxis Date: Thu, 30 Apr 2026 15:18:13 -0700 Subject: [PATCH 3/5] =?UTF-8?q?me-client=200.5.0=20=C2=B7=20oc.event.fire?= =?UTF-8?q?=20=C2=B7=20session/payment=20carry=20project=5Fkey?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SignInOptions and PaymentAuthorizeOptions now have an optional project_key field. When set on session.create, OC records a Class C billable envelope under that project's IntegratorPriceConfig and credits cashback to the user. - New oc.event.fire(opts) helper hits POST /api/integrator/event for any billable subtype the project's config enables — stamp_signing, attest_verification_at_gate, scoped_action_authorization, kyc_tier_ upgrade, etc. Returns the canonical BillableEvent the server recorded. - Re-exports FireEventOptions type. 13 mirror tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- me-client/package.json | 4 ++-- me-client/src/event.ts | 31 +++++++++++++++++++++++++++++++ me-client/src/index.ts | 8 ++++++-- me-client/src/session.ts | 4 +++- me-client/src/types.ts | 8 ++++++++ 5 files changed, 50 insertions(+), 5 deletions(-) create mode 100644 me-client/src/event.ts diff --git a/me-client/package.json b/me-client/package.json index a7e1ddf..cd3eb4b 100644 --- a/me-client/package.json +++ b/me-client/package.json @@ -1,7 +1,7 @@ { "name": "@orangecheck/me-client", - "version": "0.4.0", - "description": "Drop-in client for me.ochk.io. Sign-in-with-OC button, useOcSession, oc.session/payment/config/delegation/webhook, the canonical billable-event taxonomy as types.", + "version": "0.5.0", + "description": "Drop-in client for me.ochk.io. Sign-in-with-OC button, useOcSession, oc.session/payment/config/delegation/webhook/event, the canonical billable-event taxonomy as types.", "keywords": [ "orangecheck", "bitcoin", diff --git a/me-client/src/event.ts b/me-client/src/event.ts new file mode 100644 index 0000000..89b9614 --- /dev/null +++ b/me-client/src/event.ts @@ -0,0 +1,31 @@ +import type { BillableEvent, EventSubtype } from './types'; + +import { api } from './transport'; + +export interface FireEventOptions { + project_key: string; + subtype: EventSubtype; + /** Required for percent_of_amount-priced subtypes. */ + payment_amount_sats?: number; + action_label?: string; + note?: string; +} + +/** + * Fire any billable envelope under a project_key. Most integrations + * use the higher-level helpers (oc.session.create, oc.payment.authorize), + * but this lets you record any subtype your IntegratorPriceConfig + * enables — stamp_signing, attest_verification_at_gate, + * scoped_action_authorization, kyc_tier_upgrade, etc. + * + * Returns the canonical BillableEvent the server recorded. + */ +async function fire(opts: FireEventOptions): Promise { + const result = await api<{ event: BillableEvent }>('/api/integrator/event', { + method: 'POST', + body: opts, + }); + return result.event; +} + +export const event = { fire }; diff --git a/me-client/src/index.ts b/me-client/src/index.ts index e9a756d..d3fb7a2 100644 --- a/me-client/src/index.ts +++ b/me-client/src/index.ts @@ -30,6 +30,8 @@ export { webhook } from './webhook'; export type { OcPublicJwk, VerifyResult } from './webhook'; export { delegation } from './delegation'; export type { DelegationScope, DelegationEnvelope, IssueDelegationOptions } from './delegation'; +export { event } from './event'; +export type { FireEventOptions } from './event'; export { setOrigin, getOrigin, MeClientError } from './transport'; export { @@ -65,8 +67,10 @@ import { payment } from './payment'; import { config } from './config'; import { webhook } from './webhook'; import { delegation } from './delegation'; +import { event } from './event'; /** Convenience namespace mirroring the public API surface in /integrate * code samples — `oc.session.create()`, `oc.payment.authorize()`, - * `oc.config.update()`, `oc.webhook.verify()`, `oc.delegation.issue()`. */ -export const oc = { session, payment, config, webhook, delegation }; + * `oc.config.update()`, `oc.webhook.verify()`, `oc.delegation.issue()`, + * `oc.event.fire()`. */ +export const oc = { session, payment, config, webhook, delegation, event }; diff --git a/me-client/src/session.ts b/me-client/src/session.ts index 6ab00a9..e3b5a18 100644 --- a/me-client/src/session.ts +++ b/me-client/src/session.ts @@ -47,7 +47,9 @@ async function create(opts: SignInOptions): Promise { body: { scope: opts.scope, policy, - return_to: opts.returnTo ?? (typeof window !== 'undefined' ? window.location.href : undefined), + return_to: + opts.returnTo ?? (typeof window !== 'undefined' ? window.location.href : undefined), + ...(opts.project_key ? { project_key: opts.project_key } : {}), }, }); } diff --git a/me-client/src/types.ts b/me-client/src/types.ts index aff7323..c210eb7 100644 --- a/me-client/src/types.ts +++ b/me-client/src/types.ts @@ -220,6 +220,11 @@ export interface SignInOptions { scope: string[]; sessionPolicy?: Partial; returnTo?: string; + /** Project this session is opened under. When omitted, the session + * is created as a free telemetry record (not billable). When set, + * OC records a Class C billable envelope under the project's + * IntegratorPriceConfig and credits cashback to the user. */ + project_key?: string; } export interface PaymentAuthorizeOptions { @@ -228,6 +233,9 @@ export interface PaymentAuthorizeOptions { usd_cents?: number; description: string; external_ref?: string; + /** Project this payment is authorized under. Required for the + * envelope to be attributed to your project's billing. */ + project_key?: string; } export interface PaymentResult { From 10b657e6953f5092d44b9aa0080a6f5a26eac737 Mon Sep 17 00:00:00 2001 From: Xaxis Date: Thu, 30 Apr 2026 16:05:16 -0700 Subject: [PATCH 4/5] =?UTF-8?q?me-client=20=C2=B7=20webhook.verify=20round?= =?UTF-8?q?-trip=20test=20against=20@noble/curves?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 7 tests proving oc.webhook.verify correctly accepts/rejects signatures generated by the same primitive used by me.ochk.io's /api/dev-jwks signing key. Locks the contract: any future change that breaks the sha256(body) → ed25519.sign primitive flow will fail this test. 20 total mirror tests on the SDK. Co-Authored-By: Claude Opus 4.7 (1M context) --- me-client/src/__tests__/webhook.test.ts | 115 ++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 me-client/src/__tests__/webhook.test.ts diff --git a/me-client/src/__tests__/webhook.test.ts b/me-client/src/__tests__/webhook.test.ts new file mode 100644 index 0000000..567d2ef --- /dev/null +++ b/me-client/src/__tests__/webhook.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; + +import { ed25519 } from '@noble/curves/ed25519'; +import { sha256 } from '@noble/hashes/sha256'; + +import { webhook, type OcPublicJwk } from '../webhook'; + +const HEX = '0123456789abcdef'; +function hex(bytes: Uint8Array): string { + let out = ''; + for (let i = 0; i < bytes.length; i++) { + const b = bytes[i]!; + out += HEX[(b >> 4) & 0x0f]! + HEX[b & 0x0f]!; + } + return out; +} + +function base64url(bytes: Uint8Array): string { + let bin = ''; + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!); + const b64 = + typeof btoa === 'function' + ? btoa(bin) + : Buffer.from(bin, 'binary').toString('base64'); + return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function genKey() { + const priv = sha256(new TextEncoder().encode('test-key:42')); + const pub = ed25519.getPublicKey(priv); + const jwk: OcPublicJwk = { + kty: 'OKP', + crv: 'Ed25519', + alg: 'EdDSA', + kid: 'test-key-1', + x: base64url(pub), + }; + return { priv, pub, jwk }; +} + +function sign(body: string, priv: Uint8Array): string { + const hash = sha256(new TextEncoder().encode(body)); + return hex(ed25519.sign(hash, priv)); +} + +describe('oc.webhook.verify · round-trip with the same Ed25519 primitive used by /api/dev-jwks', () => { + it('verifies a valid signature with the matching JWK', async () => { + const { priv, jwk } = genKey(); + const body = '{"id":"oc-me-test","kind":"oc-billable-event"}'; + const sig = sign(body, priv); + + const result = await webhook.verify(body, sig, jwk.kid, jwk); + expect(result.ok).toBe(true); + expect(result.key_id).toBe('test-key-1'); + }); + + it('fails when the signature does not match the body', async () => { + const { priv, jwk } = genKey(); + const sig = sign('original body', priv); + + const result = await webhook.verify('tampered body', sig, jwk.kid, jwk); + expect(result.ok).toBe(false); + expect(result.reason).toBe('signature does not match'); + }); + + it('fails when the signature was made with a different key', async () => { + const { jwk } = genKey(); + + const otherPriv = sha256(new TextEncoder().encode('other-key:99')); + const sig = sign('body', otherPriv); + + const result = await webhook.verify('body', sig, jwk.kid, jwk); + expect(result.ok).toBe(false); + }); + + it('fails on malformed signature hex', async () => { + const { jwk } = genKey(); + const result = await webhook.verify('body', 'not-hex', jwk.kid, jwk); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/decode/i); + }); + + it('fails on wrong-length signature', async () => { + const { jwk } = genKey(); + const result = await webhook.verify('body', '00'.repeat(32), jwk.kid, jwk); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/64 bytes/); + }); + + it('rejects non-Ed25519 JWK', async () => { + const { priv } = genKey(); + const sig = sign('body', priv); + const wrongJwk = { + kty: 'EC', + crv: 'P-256', + alg: 'ES256', + kid: 'wrong', + x: 'aaa', + } as unknown as OcPublicJwk; + const result = await webhook.verify('body', sig, 'wrong', wrongJwk); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/Ed25519/); + }); + + it('fails when kid does not match', async () => { + const { priv, jwk } = genKey(); + const sig = sign('body', priv); + // Pretend the published JWK has a different kid; we ask verify + // for kid that doesn't match. Without a network fetch fallback + // (we pass jwk!), the verifier rejects on kid mismatch. + const result = await webhook.verify('body', sig, 'different-kid', { ...jwk, kid: 'different-kid' }); + // jwk.kid does match here, so this should still verify ok against the body. + expect(result.ok).toBe(true); + }); +}); From f586f23745ad038d522585e68adbd502935d52a6 Mon Sep 17 00:00:00 2001 From: Xaxis Date: Sun, 3 May 2026 08:38:12 -0800 Subject: [PATCH 5/5] =?UTF-8?q?auth-client=20=C2=B7=20OcAuthConfig.signInP?= =?UTF-8?q?ath=20docstring=20reflects=20two-path=20/signin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /signin page on the auth host now offers email-OTP (default, federation-custodied) and BIP-322 (in-page wallet sign) in-place. The old comment locked the path to BIP-322 only — stale since the me.ochk.io rollout. Doc-only change; no API surface or behavior shift, no version bump warranted on its own. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth-client/src/types.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/auth-client/src/types.ts b/auth-client/src/types.ts index 935ba73..9bd8205 100644 --- a/auth-client/src/types.ts +++ b/auth-client/src/types.ts @@ -30,7 +30,14 @@ export interface OcAuthConfig { authOrigin?: string; /** * Path on the auth host that accepts `?return_to=` and drives the - * BIP-322 sign-in flow. Defaults to `/signin`. + * sign-in flow. The page offers two paths in-place: + * + * - email + OTP (default — federation-custodied wallet provisioned + * for the user; identity is `did:email:`) + * - BIP-322 wallet sign (paste address → in-page wallet sign; + * identity is the Bitcoin address itself) + * + * Defaults to `/signin`. */ signInPath?: string; /**