Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion auth-client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,14 @@ export interface OcAuthConfig {
authOrigin?: string;
/**
* Path on the auth host that accepts `?return_to=<url>` 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:<sha256(email)>`)
* - BIP-322 wallet sign (paste address → in-page wallet sign;
* identity is the Bitcoin address itself)
*
* Defaults to `/signin`.
*/
signInPath?: string;
/**
Expand Down
8 changes: 6 additions & 2 deletions me-client/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@orangecheck/me-client",
"version": "0.2.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.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",
Expand Down Expand Up @@ -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",
Expand Down
170 changes: 165 additions & 5 deletions me-client/src/__tests__/types.test.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,40 @@
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',
class: 'B',
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', () => {
Expand All @@ -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);
});
});
115 changes: 115 additions & 0 deletions me-client/src/__tests__/webhook.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
45 changes: 45 additions & 0 deletions me-client/src/config.ts
Original file line number Diff line number Diff line change
@@ -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<IntegratorPriceConfig> {
return api<IntegratorPriceConfig>('/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<IntegratorPriceConfig> {
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<IntegratorPriceConfig>('/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 };
Loading