@hanzo/pay 0.1.0

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/README.md ADDED
@@ -0,0 +1,107 @@
1
+ # @hanzo/pay
2
+
3
+ Square payments for web and native, behind one interface.
4
+
5
+ ```
6
+ pnpm add @hanzo/pay
7
+ ```
8
+
9
+ ## The shape
10
+
11
+ Three entries. The root is platform-agnostic and dependency-free — contracts, the
12
+ colour table, the idempotency-key rule — so a server, a shared module and a test
13
+ can all hold it. The adapters are explicit:
14
+
15
+ | Entry | Needs | Gives you |
16
+ |---|---|---|
17
+ | `@hanzo/pay` | nothing | `Terminal`, `Method`, `Tender`, `Token`, `PALETTE`, `legible`, `attempt` |
18
+ | `@hanzo/pay/web` | a browser | `terminal()` over the Web Payments SDK, `style()`, `pin()` |
19
+ | `@hanzo/pay/native` | `react-native-square-in-app-payments` | `terminal()` over the In-App Payments SDK, `appearance()` |
20
+
21
+ Both adapters export `terminal(config)` and both return the same `Terminal`.
22
+ Picking one by filename magic works right up until a bundler picks the other,
23
+ and then it fails at runtime, in a payment, on someone else's device — so the
24
+ host says which platform it is, in the import.
25
+
26
+ ## Taking a payment
27
+
28
+ ```ts
29
+ import { cancelled, attempt } from '@hanzo/pay'
30
+ import { terminal } from '@hanzo/pay/web'
31
+
32
+ const pay = await terminal({ applicationId, locationId, environment })
33
+ const tender = { total: { amount: '12.00', currency: 'USD' }, country: 'US', label: 'Credit' }
34
+
35
+ const rails = await pay.offers(tender) // only what can really pay, here, now
36
+ await pay.mount?.('card', '#card') // absent on native: there is no element
37
+
38
+ const key = attempt() // hold this across retries
39
+ try {
40
+ const token = await pay.collect('card', tender)
41
+ // charge server-side with token.value and `key` as the idempotency key
42
+ } catch (e) {
43
+ if (!cancelled(e)) throw e // a dismissed sheet is not a failure
44
+ }
45
+ ```
46
+
47
+ **This package never moves money.** Every rail ends at a single-use token; the
48
+ charge is a server call you make with it.
49
+
50
+ ## What it knows that you would otherwise learn the hard way
51
+
52
+ - **`offers()` asks the SDK, one rail at a time.** Availability depends on the
53
+ browser, the device, the buyer's saved cards *and* whether the merchant account
54
+ has the rail switched on. A picker listing a wallet the platform cannot use is
55
+ a dead end with a logo on it.
56
+ - **`REACH.native` has no Cash App Pay and no ACH.** Square's native SDK ships
57
+ neither. That is reach, not policy — a host may decline a rail this table
58
+ lists, and `hanzoai/pay` declines ACH on Square because its top-up endpoint
59
+ credits on charge success while an ACH debit settles days later.
60
+ - **Nothing is awaited before `tokenize()`.** Apple refuses a sheet not opened by
61
+ the gesture that asked for it, so `offers()` builds every rail up front and
62
+ `collect()` only reads a map. One stray `await` above that call breaks Apple
63
+ Pay and nothing else.
64
+ - **Cash App Pay and ACH deliver on an event, not a return value.** A caller
65
+ reading only the return gets nothing from either, silently, on a payment that
66
+ succeeded.
67
+ - **`pin()` is applied for you.** `input.backgroundColor` is accepted and then
68
+ ignored: `color-scheme` inherits into the cross-origin iframe and the UA paints
69
+ form controls over whatever the SDK declared. One rule fixes it, and it has to
70
+ be in the sheet before the card attaches — so `terminal()` puts it there rather
71
+ than leaving a stylesheet you can forget to import.
72
+ - **`attempt()` fits Square's ruler.** An `idempotency_key` over 45 characters is
73
+ rejected before authorization. A 36-char UUID under a `topup:<org>:` wrapper is
74
+ 49, which failed every card charge for one org and read as "charge failed".
75
+ - **`Detail.charge` holds the native sheet open.** Square's native card sheet
76
+ stays up after minting the token and is told afterwards whether the charge
77
+ worked — so a decline retries in place instead of dumping the buyer back to an
78
+ empty form. The web has no equivalent and ignores it.
79
+
80
+ ## Theming
81
+
82
+ One `Palette` dresses both platforms: `style()` renders it as the CSS literals
83
+ the web iframe takes, `appearance()` as the `{r,g,b,a}` components the native
84
+ sheet takes. Neither surface can read your CSS variables — one is cross-origin,
85
+ the other is not the web — which is why the table holds literals.
86
+
87
+ ```ts
88
+ import { legible, PALETTE } from '@hanzo/pay'
89
+
90
+ legible(PALETTE.dark) // [] — every contrast floor met
91
+ legible({ ...PALETTE.dark, field: '#fafafa' }) // names the pairs that fail
92
+ ```
93
+
94
+ Run `legible()` over a brand palette before shipping it. A reviewer looking at a
95
+ dark mock cannot tell 2.6:1 from 4.4:1, and the focus ring is a WCAG 2.4.11
96
+ requirement rather than a preference.
97
+
98
+ ## Native setup
99
+
100
+ `@hanzo/pay/native` needs `react-native-square-in-app-payments` and the native
101
+ build steps that plugin documents (Apple Pay entitlement and merchant id, Google
102
+ Pay via Play Services). `appearance()` is **iOS only** — Android's card entry is
103
+ themed through native XML styles at build time, with no runtime channel.
104
+
105
+ ## License
106
+
107
+ Apache-2.0
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Square's ceiling on `idempotency_key`, in characters. Not a guideline — it is
3
+ * a hard rejection, and it happens before authorization.
4
+ */
5
+ export declare const LIMIT = 45;
6
+ /**
7
+ * The smallest key worth minting.
8
+ *
9
+ * Keys are namespaced per org by the wrapper, so a collision inside one org
10
+ * would replay ANOTHER buyer's charge — the one failure mode worse than
11
+ * double-charging. Below this there is not enough entropy to rule that out, and
12
+ * silently minting a short key would trade a loud error for a quiet one.
13
+ */
14
+ export declare const FLOOR = 8;
15
+ /**
16
+ * The largest key worth minting: a whole UUID with its hyphens removed.
17
+ *
18
+ * 128 bits already rules out collision by any margin that matters, so a budget
19
+ * bigger than this buys nothing. Naming the ceiling is what stops `attempt()`
20
+ * quietly returning fewer characters than it was asked for — which it did, and
21
+ * which its own test caught: 45 minus a 10-character wrapper is 35, and a
22
+ * stripped UUID is 32.
23
+ */
24
+ export declare const CEILING = 32;
25
+ /**
26
+ * A key identifying ONE payment attempt, sized to survive being wrapped.
27
+ *
28
+ * Hold it across retries — that is the entire point. A retry carrying the same
29
+ * key replays the first result instead of charging again, even a retry that had
30
+ * to re-tokenize a fresh single-use token because the buyer pressed "start
31
+ * over". A key minted per REQUEST rather than per ATTEMPT provides no protection
32
+ * whatsoever, and looks identical from here.
33
+ *
34
+ * `reserved` is how many characters the server will prepend. The default 25 is
35
+ * commerce's worst case and yields the 20-char key hanzoai/pay ships:
36
+ * `subscribe:` (10) + an org slug + `:` (1), which leaves room for slugs up to
37
+ * 14 even under the longest op. A host whose wrapper is shorter may pass a
38
+ * smaller number and get a longer key; none of them needs a longer key.
39
+ *
40
+ * Entropy comes from `crypto.randomUUID` where it exists. The fallback is time
41
+ * plus `Math.random`, which is weaker and deliberately not silent about it —
42
+ * see the note there.
43
+ */
44
+ export declare function attempt(reserved?: number): string;
45
+ /**
46
+ * Cut a key that was minted before anyone knew about the ceiling.
47
+ *
48
+ * Shortening a STORED key cannot skip a replay, and that is what makes this safe
49
+ * rather than reckless: Square never accepted the long key, so no charge exists
50
+ * under it to replay. A session holding a 36-character UUID would otherwise
51
+ * re-fail forever, having carefully preserved the exact value that cannot work.
52
+ */
53
+ export declare function clamp(key: string, reserved?: number): string;
@@ -0,0 +1,87 @@
1
+ 'use strict';
2
+
3
+ // src/terminal.ts
4
+ var Cancelled = class extends Error {
5
+ method;
6
+ constructor(method) {
7
+ super(`${method} was cancelled`);
8
+ this.name = "Cancelled";
9
+ this.method = method;
10
+ }
11
+ };
12
+ function cancelled(e) {
13
+ return e instanceof Cancelled;
14
+ }
15
+ var REACH = {
16
+ web: ["card", "apple_pay", "google_pay", "cash_app", "ach", "gift"],
17
+ native: ["card", "apple_pay", "google_pay", "gift"]
18
+ };
19
+
20
+ // src/palette.ts
21
+ var PALETTE = {
22
+ dark: {
23
+ field: "#0a0a0a",
24
+ // --card
25
+ border: "#232323",
26
+ // --white-10 over --card
27
+ borderFocus: "#787878",
28
+ // --white-45 over --card — 4.4:1 on #0a0a0a
29
+ text: "#ededed",
30
+ // --foreground
31
+ placeholder: "#888888",
32
+ // --muted-foreground
33
+ error: "#fca5a5"
34
+ // --state-error-text (red-300), legible on black
35
+ },
36
+ light: {
37
+ field: "#ffffff",
38
+ // --background
39
+ border: "#e5e5e5",
40
+ // --border (neutral-200)
41
+ borderFocus: "#737373",
42
+ // --neutral-500 — 4.7:1 on #ffffff
43
+ text: "#0a0a0a",
44
+ // --foreground
45
+ placeholder: "#525252",
46
+ // --muted-foreground
47
+ error: "#ef4444"
48
+ // --state-error (red-500), legible on white
49
+ }
50
+ };
51
+ var HEX = /^#[0-9a-f]{6}$/;
52
+ function luminance(hex) {
53
+ const c = [1, 3, 5].map((i) => {
54
+ const v = parseInt(hex.slice(i, i + 2), 16) / 255;
55
+ return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
56
+ });
57
+ return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
58
+ }
59
+ function ratio(a, b) {
60
+ const [x, y] = [luminance(a), luminance(b)].sort((m, n) => n - m);
61
+ return (x + 0.05) / (y + 0.05);
62
+ }
63
+ function legible(p) {
64
+ const pairs = [
65
+ // The typed card number, at AA for body text.
66
+ ["text on field", p.text, p.field, 4.5],
67
+ // A placeholder that is merely present, not legible, is a label nobody reads.
68
+ ["placeholder on field", p.placeholder, p.field, 4.5],
69
+ // Non-text contrast: this border IS the focus indicator (WCAG 2.4.11).
70
+ ["borderFocus on field", p.borderFocus, p.field, 3],
71
+ // The error hue carries both text and a border, so it is held to the lower
72
+ // non-text floor — it is never the only signal, the border moves too.
73
+ ["error on field", p.error, p.field, 3]
74
+ ];
75
+ return pairs.map(([pair, a, b, floor]) => ({ pair, ratio: ratio(a, b), floor })).filter((f) => f.ratio < f.floor);
76
+ }
77
+
78
+ exports.Cancelled = Cancelled;
79
+ exports.HEX = HEX;
80
+ exports.PALETTE = PALETTE;
81
+ exports.REACH = REACH;
82
+ exports.cancelled = cancelled;
83
+ exports.legible = legible;
84
+ exports.luminance = luminance;
85
+ exports.ratio = ratio;
86
+ //# sourceMappingURL=chunk-64AUOMEL.cjs.map
87
+ //# sourceMappingURL=chunk-64AUOMEL.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/terminal.ts","../src/palette.ts"],"names":[],"mappings":";;;AAuJO,IAAM,SAAA,GAAN,cAAwB,KAAA,CAAM;AAAA,EAC1B,MAAA;AAAA,EACT,YAAY,MAAA,EAAgB;AAC1B,IAAA,KAAA,CAAM,CAAA,EAAG,MAAM,CAAA,cAAA,CAAgB,CAAA;AAC/B,IAAA,IAAA,CAAK,IAAA,GAAO,WAAA;AACZ,IAAA,IAAA,CAAK,MAAA,GAAS,MAAA;AAAA,EAChB;AACF;AAGO,SAAS,UAAU,CAAA,EAA4B;AACpD,EAAA,OAAO,CAAA,YAAa,SAAA;AACtB;AAuBO,IAAM,KAAA,GAAqD;AAAA,EAChE,KAAK,CAAC,MAAA,EAAQ,aAAa,YAAA,EAAc,UAAA,EAAY,OAAO,MAAM,CAAA;AAAA,EAClE,MAAA,EAAQ,CAAC,MAAA,EAAQ,WAAA,EAAa,cAAc,MAAM;AACpD;;;ACtIO,IAAM,OAAA,GAAkC;AAAA,EAC7C,IAAA,EAAM;AAAA,IACJ,KAAA,EAAO,SAAA;AAAA;AAAA,IACP,MAAA,EAAQ,SAAA;AAAA;AAAA,IACR,WAAA,EAAa,SAAA;AAAA;AAAA,IACb,IAAA,EAAM,SAAA;AAAA;AAAA,IACN,WAAA,EAAa,SAAA;AAAA;AAAA,IACb,KAAA,EAAO;AAAA;AAAA,GACT;AAAA,EACA,KAAA,EAAO;AAAA,IACL,KAAA,EAAO,SAAA;AAAA;AAAA,IACP,MAAA,EAAQ,SAAA;AAAA;AAAA,IACR,WAAA,EAAa,SAAA;AAAA;AAAA,IACb,IAAA,EAAM,SAAA;AAAA;AAAA,IACN,WAAA,EAAa,SAAA;AAAA;AAAA,IACb,KAAA,EAAO;AAAA;AAAA;AAEX;AAGO,IAAM,GAAA,GAAM;AAGZ,SAAS,UAAU,GAAA,EAAqB;AAC7C,EAAA,MAAM,CAAA,GAAI,CAAC,CAAA,EAAG,CAAA,EAAG,CAAC,CAAA,CAAE,GAAA,CAAI,CAAC,CAAA,KAAM;AAC7B,IAAA,MAAM,CAAA,GAAI,SAAS,GAAA,CAAI,KAAA,CAAM,GAAG,CAAA,GAAI,CAAC,CAAA,EAAG,EAAE,CAAA,GAAI,GAAA;AAC9C,IAAA,OAAO,KAAK,OAAA,GAAU,CAAA,GAAI,KAAA,GAAA,CAAA,CAAU,CAAA,GAAI,SAAS,KAAA,KAAU,GAAA;AAAA,EAC7D,CAAC,CAAA;AACD,EAAA,OAAO,MAAA,GAAS,CAAA,CAAE,CAAC,CAAA,GAAI,MAAA,GAAS,EAAE,CAAC,CAAA,GAAI,MAAA,GAAS,CAAA,CAAE,CAAC,CAAA;AACrD;AAGO,SAAS,KAAA,CAAM,GAAW,CAAA,EAAmB;AAClD,EAAA,MAAM,CAAC,CAAA,EAAG,CAAC,CAAA,GAAI,CAAC,UAAU,CAAC,CAAA,EAAG,SAAA,CAAU,CAAC,CAAC,CAAA,CAAE,IAAA,CAAK,CAAC,CAAA,EAAG,CAAA,KAAM,IAAI,CAAC,CAAA;AAChE,EAAA,OAAA,CAAQ,CAAA,GAAI,SAAS,CAAA,GAAI,IAAA,CAAA;AAC3B;AAkBO,SAAS,QAAQ,CAAA,EAAqB;AAC3C,EAAA,MAAM,KAAA,GAAiD;AAAA;AAAA,IAErD,CAAC,eAAA,EAAiB,CAAA,CAAE,IAAA,EAAM,CAAA,CAAE,OAAO,GAAG,CAAA;AAAA;AAAA,IAEtC,CAAC,sBAAA,EAAwB,CAAA,CAAE,WAAA,EAAa,CAAA,CAAE,OAAO,GAAG,CAAA;AAAA;AAAA,IAEpD,CAAC,sBAAA,EAAwB,CAAA,CAAE,WAAA,EAAa,CAAA,CAAE,OAAO,CAAC,CAAA;AAAA;AAAA;AAAA,IAGlD,CAAC,gBAAA,EAAkB,CAAA,CAAE,KAAA,EAAO,CAAA,CAAE,OAAO,CAAC;AAAA,GACxC;AACA,EAAA,OAAO,KAAA,CACJ,GAAA,CAAI,CAAC,CAAC,IAAA,EAAM,GAAG,CAAA,EAAG,KAAK,CAAA,MAAO,EAAE,IAAA,EAAM,KAAA,EAAO,MAAM,CAAA,EAAG,CAAC,CAAA,EAAG,KAAA,EAAM,CAAE,CAAA,CAClE,MAAA,CAAO,CAAC,CAAA,KAAM,CAAA,CAAE,KAAA,GAAQ,CAAA,CAAE,KAAK,CAAA;AACpC","file":"chunk-64AUOMEL.cjs","sourcesContent":["// What a checkout asks for, what it gets back, and the one thing that turns the\n// first into the second. Platform-agnostic: no DOM, no react-native, no Square.\n//\n// THIS PACKAGE NEVER MOVES MONEY. Every rail below ends at a single-use token,\n// and the charge is a server call the host makes with that token. Nothing here\n// completes a payment, which is why the noun is `Tender` — an OFFER of payment,\n// the term Square's own Orders API uses — and not `Charge`, which would name\n// something this code cannot do.\n\n/**\n * A Square rail.\n *\n * `gift` is a Square gift card, which tokenizes like a card but through its own\n * constructor on both platforms. Non-Square rails a host may also offer (wire,\n * crypto, another processor) are deliberately absent: this package knows Square,\n * and a host that knows more merges its own set with what `offers()` returns.\n */\nexport type Method = 'card' | 'apple_pay' | 'google_pay' | 'cash_app' | 'ach' | 'gift'\n\n/**\n * Money as a DECIMAL STRING, never a number.\n *\n * `0.1 + 0.2` is `0.30000000000000004`, and a float that reaches a payment\n * processor as a total is a rounding error someone is charged. Square's own\n * paymentRequest takes `amount` as a string for this reason; so does this.\n */\nexport interface Money {\n /** Decimal string in major units, e.g. `\"12.00\"`. Never a float. */\n amount: string\n /** ISO 4217, e.g. `\"USD\"`. */\n currency: string\n}\n\n/** An offer of payment: what is being paid, where, and what the buyer is shown. */\nexport interface Tender {\n total: Money\n /** ISO 3166-1 alpha-2 of the MERCHANT, e.g. `\"US\"`. Square keys wallets off it. */\n country: string\n /** The line the wallet sheet shows the buyer, e.g. `\"Hanzo AI credit\"`. */\n label: string\n}\n\n/**\n * A single-use payment token, and the only thing any rail here produces.\n *\n * It is single-use in the strict sense: spending it — charging it OR vaulting it\n * — consumes it. A flow that needs both a charge and a card-on-file must\n * tokenize TWICE; sharing one token between the two fails with the card already\n * accepted, which reads as a decline.\n */\nexport interface Token {\n value: string\n method: Method\n /** Present when the rail reported it. Absent means never reported, not empty. */\n card?: { brand?: string; last4?: string; expMonth?: number; expYear?: number }\n}\n\n/**\n * Rail-specific facts a `Tender` cannot carry, because only one rail needs each.\n * Every field is ignored by the rails it does not belong to.\n */\nexport interface Detail {\n /**\n * ACH only (web), and REQUIRED there: Square matches it against the bank\n * record. Collected from the buyer rather than derived from the account,\n * because the account holder and the person paying are not always the same\n * name.\n */\n name?: string\n /**\n * Card and gift only. Passed straight to Square as verification details —\n * `{ intent, amount, currencyCode, billingContact, customerInitiated,\n * sellerKeyedIn }` — which is how SCA is satisfied in mandated regions.\n * Opaque on purpose: Square owns this shape, and re-declaring it here would\n * be a second copy to drift.\n */\n verify?: Record<string, unknown>\n /**\n * NATIVE ONLY, and the one real asymmetry between the platforms.\n *\n * Square's native card sheet is a state machine, not a form: it STAYS OPEN\n * after minting the token, showing a spinner, until it is told whether the\n * charge worked. Told yes, it closes. Told no, it shows the message ON the\n * sheet with the card still entered, so the buyer retries in place instead of\n * being dropped back to a checkout with an empty form and a decline notice.\n *\n * That is a better decline experience than the web can offer, and it exists\n * only if the host charges DURING the sheet — which is what this is. Given the\n * token, run the server call; return to close the sheet, throw to keep it open\n * with the thrown message shown.\n *\n * Omit it and the sheet closes as soon as the token exists, which is what the\n * web does. Nothing hangs either way.\n *\n * The web backend ignores this: its card fields are a page element with no\n * sheet to hold open.\n */\n charge?(token: Token): Promise<void>\n}\n\n/**\n * The one interface both backends satisfy: web (Square Web Payments SDK) and\n * native (Square In-App Payments SDK). A host writes against this and the\n * platform picks the implementation — see `./web` and `./native`.\n */\nexport interface Terminal {\n /**\n * Which methods can REALLY complete a payment here, right now.\n *\n * Asked of the SDK, never inferred from a user-agent string: the answer depends\n * on the browser or device, on the buyer's saved cards, AND on whether the\n * merchant account has the rail switched on. A picker listing a wallet the\n * platform cannot use is a dead end with a logo on it.\n *\n * Takes the tender because the answer depends on it — Square binds the total\n * into the wallet it builds, so this is asked at the point of payment with the\n * real amount rather than cached at boot.\n */\n offers(tender: Tender): Promise<Method[]>\n\n /**\n * Draw the rails that render INLINE into the page, and nothing else.\n *\n * Absent on native, and that absence is the honest signal: native card entry\n * is a modal the OS presents, so there is no element to place and nothing for\n * a caller to position. A host writes `terminal.mount?.(…)` and the native\n * build correctly does nothing.\n */\n mount?(method: Method, target: string): Promise<void>\n\n /**\n * Tokenize. Resolves with a token, or rejects.\n *\n * A buyer who dismisses the sheet has not failed — `collect` rejects with a\n * `Cancelled` (see below), which a caller distinguishes from a real failure so\n * it does not show an error for a decision.\n */\n collect(method: Method, tender: Tender, detail?: Detail): Promise<Token>\n\n /** Tear down every element and listener this terminal owns. */\n release(): Promise<void>\n}\n\n/**\n * The buyer dismissed the sheet. NOT an error to show anyone.\n *\n * Every rail signals this differently — Square's web tokenize resolves with\n * `status: 'CANCEL'`, Apple Pay on native calls a cancel callback, Google Pay\n * rejects with its own code — and a caller that cannot tell them apart shows\n * \"payment failed\" to someone who simply changed their mind.\n */\nexport class Cancelled extends Error {\n readonly method: Method\n constructor(method: Method) {\n super(`${method} was cancelled`)\n this.name = 'Cancelled'\n this.method = method\n }\n}\n\n/** True for the one rejection that means \"the buyer said no\", on any platform. */\nexport function cancelled(e: unknown): e is Cancelled {\n return e instanceof Cancelled\n}\n\n/**\n * The rails each platform's Square SDK can even ATTEMPT.\n *\n * This is a fact about the SDKs, not about a merchant account, and the two do not\n * agree: Square's In-App Payments SDK ships no Cash App Pay and no ACH — its\n * whole exported surface is card entry, gift card entry, Apple Pay, Google Pay\n * and buyer verification. Offering either on a phone would be a button that\n * cannot resolve.\n *\n * `offers()` intersects this with what the SDK says it can build, so a rail\n * missing here is absent from the picker rather than present and dead.\n *\n * THIS IS REACH, NOT POLICY, and the difference has already been mistaken once.\n * A host may decline a rail this table lists, for reasons that have nothing to\n * do with tokenizing: hanzoai/pay does not offer ACH on Square, because its\n * top-up endpoint credits the balance as soon as the charge call succeeds while\n * an ACH debit settles days later — so it would credit unsettled money. The SDK\n * reaches ACH perfectly well. Deciding not to use it is the host's call and does\n * not belong here; deleting `ach` below would instead tell every other host that\n * the rail does not exist.\n */\nexport const REACH: Record<'web' | 'native', readonly Method[]> = {\n web: ['card', 'apple_pay', 'google_pay', 'cash_app', 'ach', 'gift'],\n native: ['card', 'apple_pay', 'google_pay', 'gift'],\n}\n","// The card field's colours — the ONE place this package writes a colour down.\n//\n// Everything else on a checkout is styled from the host's own tokens, so a theme\n// change is a class on <html> and no JS is involved. The card fields cannot work\n// that way, and the reason is the same on both platforms for different causes:\n//\n// web Square renders the fields in a CROSS-ORIGIN iframe, which cannot read\n// our custom properties. The only channel is the style object handed to\n// `payments.card({ style })`, and it takes literal values.\n// native Square renders the fields in a NATIVE view. There is no CSS at all;\n// the only channel is `SQIPCardEntry.setIOSCardEntryTheme`, which takes\n// {r,g,b,a} components.\n//\n// So the literals are unavoidable on both. What is avoidable is having them\n// scattered, or drifting from the palette they mirror, or disagreeing between web\n// and native — hence ONE table here, rendered per platform by each backend\n// (`style()` in ./web, `theme()` in ./native). A brand supplies its own table and\n// gets both renderings for free.\n//\n// Every value is an OPAQUE hex. Square validates style values against its own\n// allowlist and rejects what it cannot parse, and an alpha channel over a surface\n// we do not control is a guess about what is behind it anyway. Alpha rungs of a\n// design ladder (white/10, white/40) are therefore composited onto the field's\n// own ground here, which is what the eye sees regardless.\n\n/** The two grounds a checkout can sit on. Dark is the default. */\nexport type Theme = 'dark' | 'light'\n\n// A PALETTE IS ONE SURFACE, and `field` + `text` are the pair that makes it\n// readable. They have forked twice in production, in both directions: once as a\n// white ground under near-white ink (`field: 'transparent'`, so Square's own\n// white showed through), and once as near-black ink placed on this ground on the\n// belief that the ground was still white. Both shipped a card number the customer\n// could not see while typing it. Neither is a judgement call — `legible()` below\n// measures the pair, and the first of those two states measures 1.0 against a 4.5\n// floor. Change a ground and its ink in one edit, and let the check say whether\n// the result can be read.\nexport interface Palette {\n /** The field's own ground. */\n field: string\n /** Hairline at rest. */\n border: string\n /** Focused hairline. Deliberately >= 3:1 on `field`: it IS the focus indicator. */\n borderFocus: string\n /** Typed characters. */\n text: string\n /** Placeholder text. */\n placeholder: string\n /** Validation text, icon and border. One error hue. */\n error: string\n}\n\n// Both columns are @hanzo/design's own values, not new ones. The dark column is\n// the palette `:root` publishes; the light column is what `.light` inverts it to.\n// Composited alphas are noted where the token is a rung rather than a hex.\nexport const PALETTE: Record<Theme, Palette> = {\n dark: {\n field: '#0a0a0a', // --card\n border: '#232323', // --white-10 over --card\n borderFocus: '#787878', // --white-45 over --card — 4.4:1 on #0a0a0a\n text: '#ededed', // --foreground\n placeholder: '#888888', // --muted-foreground\n error: '#fca5a5', // --state-error-text (red-300), legible on black\n },\n light: {\n field: '#ffffff', // --background\n border: '#e5e5e5', // --border (neutral-200)\n borderFocus: '#737373', // --neutral-500 — 4.7:1 on #ffffff\n text: '#0a0a0a', // --foreground\n placeholder: '#525252', // --muted-foreground\n error: '#ef4444', // --state-error (red-500), legible on white\n },\n}\n\n/** `#rrggbb`, lowercase. The only form Square accepts on both platforms. */\nexport const HEX = /^#[0-9a-f]{6}$/\n\n/** Relative luminance, WCAG 2.x. */\nexport function luminance(hex: string): number {\n const c = [1, 3, 5].map((i) => {\n const v = parseInt(hex.slice(i, i + 2), 16) / 255\n return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4\n })\n return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]\n}\n\n/** Contrast ratio between two opaque hexes, 1..21. Order does not matter. */\nexport function ratio(a: string, b: string): number {\n const [x, y] = [luminance(a), luminance(b)].sort((m, n) => n - m)\n return (x + 0.05) / (y + 0.05)\n}\n\n/** One failed contrast pair: what was measured, against what floor. */\nexport interface Fault {\n pair: string\n ratio: number\n floor: number\n}\n\n/**\n * Every contrast pair in a palette that a customer must be able to read, with\n * the WCAG floor each is held to.\n *\n * This is exported rather than kept in a test because a BRAND supplying its own\n * table needs the same check, and the failure it catches is invisible in review:\n * a reviewer looking at a dark mock cannot tell 2.6:1 from 4.4:1, and the focus\n * ring is a WCAG 2.4.11 requirement rather than a preference.\n */\nexport function legible(p: Palette): Fault[] {\n const pairs: Array<[string, string, string, number]> = [\n // The typed card number, at AA for body text.\n ['text on field', p.text, p.field, 4.5],\n // A placeholder that is merely present, not legible, is a label nobody reads.\n ['placeholder on field', p.placeholder, p.field, 4.5],\n // Non-text contrast: this border IS the focus indicator (WCAG 2.4.11).\n ['borderFocus on field', p.borderFocus, p.field, 3],\n // The error hue carries both text and a border, so it is held to the lower\n // non-text floor — it is never the only signal, the border moves too.\n ['error on field', p.error, p.field, 3],\n ]\n return pairs\n .map(([pair, a, b, floor]) => ({ pair, ratio: ratio(a, b), floor }))\n .filter((f) => f.ratio < f.floor)\n}\n"]}
@@ -0,0 +1,78 @@
1
+ // src/terminal.ts
2
+ var Cancelled = class extends Error {
3
+ method;
4
+ constructor(method) {
5
+ super(`${method} was cancelled`);
6
+ this.name = "Cancelled";
7
+ this.method = method;
8
+ }
9
+ };
10
+ function cancelled(e) {
11
+ return e instanceof Cancelled;
12
+ }
13
+ var REACH = {
14
+ web: ["card", "apple_pay", "google_pay", "cash_app", "ach", "gift"],
15
+ native: ["card", "apple_pay", "google_pay", "gift"]
16
+ };
17
+
18
+ // src/palette.ts
19
+ var PALETTE = {
20
+ dark: {
21
+ field: "#0a0a0a",
22
+ // --card
23
+ border: "#232323",
24
+ // --white-10 over --card
25
+ borderFocus: "#787878",
26
+ // --white-45 over --card — 4.4:1 on #0a0a0a
27
+ text: "#ededed",
28
+ // --foreground
29
+ placeholder: "#888888",
30
+ // --muted-foreground
31
+ error: "#fca5a5"
32
+ // --state-error-text (red-300), legible on black
33
+ },
34
+ light: {
35
+ field: "#ffffff",
36
+ // --background
37
+ border: "#e5e5e5",
38
+ // --border (neutral-200)
39
+ borderFocus: "#737373",
40
+ // --neutral-500 — 4.7:1 on #ffffff
41
+ text: "#0a0a0a",
42
+ // --foreground
43
+ placeholder: "#525252",
44
+ // --muted-foreground
45
+ error: "#ef4444"
46
+ // --state-error (red-500), legible on white
47
+ }
48
+ };
49
+ var HEX = /^#[0-9a-f]{6}$/;
50
+ function luminance(hex) {
51
+ const c = [1, 3, 5].map((i) => {
52
+ const v = parseInt(hex.slice(i, i + 2), 16) / 255;
53
+ return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
54
+ });
55
+ return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
56
+ }
57
+ function ratio(a, b) {
58
+ const [x, y] = [luminance(a), luminance(b)].sort((m, n) => n - m);
59
+ return (x + 0.05) / (y + 0.05);
60
+ }
61
+ function legible(p) {
62
+ const pairs = [
63
+ // The typed card number, at AA for body text.
64
+ ["text on field", p.text, p.field, 4.5],
65
+ // A placeholder that is merely present, not legible, is a label nobody reads.
66
+ ["placeholder on field", p.placeholder, p.field, 4.5],
67
+ // Non-text contrast: this border IS the focus indicator (WCAG 2.4.11).
68
+ ["borderFocus on field", p.borderFocus, p.field, 3],
69
+ // The error hue carries both text and a border, so it is held to the lower
70
+ // non-text floor — it is never the only signal, the border moves too.
71
+ ["error on field", p.error, p.field, 3]
72
+ ];
73
+ return pairs.map(([pair, a, b, floor]) => ({ pair, ratio: ratio(a, b), floor })).filter((f) => f.ratio < f.floor);
74
+ }
75
+
76
+ export { Cancelled, HEX, PALETTE, REACH, cancelled, legible, luminance, ratio };
77
+ //# sourceMappingURL=chunk-DXK2QDTX.js.map
78
+ //# sourceMappingURL=chunk-DXK2QDTX.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/terminal.ts","../src/palette.ts"],"names":[],"mappings":"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","file":"chunk-DXK2QDTX.js","sourcesContent":["// What a checkout asks for, what it gets back, and the one thing that turns the\n// first into the second. Platform-agnostic: no DOM, no react-native, no Square.\n//\n// THIS PACKAGE NEVER MOVES MONEY. Every rail below ends at a single-use token,\n// and the charge is a server call the host makes with that token. Nothing here\n// completes a payment, which is why the noun is `Tender` — an OFFER of payment,\n// the term Square's own Orders API uses — and not `Charge`, which would name\n// something this code cannot do.\n\n/**\n * A Square rail.\n *\n * `gift` is a Square gift card, which tokenizes like a card but through its own\n * constructor on both platforms. Non-Square rails a host may also offer (wire,\n * crypto, another processor) are deliberately absent: this package knows Square,\n * and a host that knows more merges its own set with what `offers()` returns.\n */\nexport type Method = 'card' | 'apple_pay' | 'google_pay' | 'cash_app' | 'ach' | 'gift'\n\n/**\n * Money as a DECIMAL STRING, never a number.\n *\n * `0.1 + 0.2` is `0.30000000000000004`, and a float that reaches a payment\n * processor as a total is a rounding error someone is charged. Square's own\n * paymentRequest takes `amount` as a string for this reason; so does this.\n */\nexport interface Money {\n /** Decimal string in major units, e.g. `\"12.00\"`. Never a float. */\n amount: string\n /** ISO 4217, e.g. `\"USD\"`. */\n currency: string\n}\n\n/** An offer of payment: what is being paid, where, and what the buyer is shown. */\nexport interface Tender {\n total: Money\n /** ISO 3166-1 alpha-2 of the MERCHANT, e.g. `\"US\"`. Square keys wallets off it. */\n country: string\n /** The line the wallet sheet shows the buyer, e.g. `\"Hanzo AI credit\"`. */\n label: string\n}\n\n/**\n * A single-use payment token, and the only thing any rail here produces.\n *\n * It is single-use in the strict sense: spending it — charging it OR vaulting it\n * — consumes it. A flow that needs both a charge and a card-on-file must\n * tokenize TWICE; sharing one token between the two fails with the card already\n * accepted, which reads as a decline.\n */\nexport interface Token {\n value: string\n method: Method\n /** Present when the rail reported it. Absent means never reported, not empty. */\n card?: { brand?: string; last4?: string; expMonth?: number; expYear?: number }\n}\n\n/**\n * Rail-specific facts a `Tender` cannot carry, because only one rail needs each.\n * Every field is ignored by the rails it does not belong to.\n */\nexport interface Detail {\n /**\n * ACH only (web), and REQUIRED there: Square matches it against the bank\n * record. Collected from the buyer rather than derived from the account,\n * because the account holder and the person paying are not always the same\n * name.\n */\n name?: string\n /**\n * Card and gift only. Passed straight to Square as verification details —\n * `{ intent, amount, currencyCode, billingContact, customerInitiated,\n * sellerKeyedIn }` — which is how SCA is satisfied in mandated regions.\n * Opaque on purpose: Square owns this shape, and re-declaring it here would\n * be a second copy to drift.\n */\n verify?: Record<string, unknown>\n /**\n * NATIVE ONLY, and the one real asymmetry between the platforms.\n *\n * Square's native card sheet is a state machine, not a form: it STAYS OPEN\n * after minting the token, showing a spinner, until it is told whether the\n * charge worked. Told yes, it closes. Told no, it shows the message ON the\n * sheet with the card still entered, so the buyer retries in place instead of\n * being dropped back to a checkout with an empty form and a decline notice.\n *\n * That is a better decline experience than the web can offer, and it exists\n * only if the host charges DURING the sheet — which is what this is. Given the\n * token, run the server call; return to close the sheet, throw to keep it open\n * with the thrown message shown.\n *\n * Omit it and the sheet closes as soon as the token exists, which is what the\n * web does. Nothing hangs either way.\n *\n * The web backend ignores this: its card fields are a page element with no\n * sheet to hold open.\n */\n charge?(token: Token): Promise<void>\n}\n\n/**\n * The one interface both backends satisfy: web (Square Web Payments SDK) and\n * native (Square In-App Payments SDK). A host writes against this and the\n * platform picks the implementation — see `./web` and `./native`.\n */\nexport interface Terminal {\n /**\n * Which methods can REALLY complete a payment here, right now.\n *\n * Asked of the SDK, never inferred from a user-agent string: the answer depends\n * on the browser or device, on the buyer's saved cards, AND on whether the\n * merchant account has the rail switched on. A picker listing a wallet the\n * platform cannot use is a dead end with a logo on it.\n *\n * Takes the tender because the answer depends on it — Square binds the total\n * into the wallet it builds, so this is asked at the point of payment with the\n * real amount rather than cached at boot.\n */\n offers(tender: Tender): Promise<Method[]>\n\n /**\n * Draw the rails that render INLINE into the page, and nothing else.\n *\n * Absent on native, and that absence is the honest signal: native card entry\n * is a modal the OS presents, so there is no element to place and nothing for\n * a caller to position. A host writes `terminal.mount?.(…)` and the native\n * build correctly does nothing.\n */\n mount?(method: Method, target: string): Promise<void>\n\n /**\n * Tokenize. Resolves with a token, or rejects.\n *\n * A buyer who dismisses the sheet has not failed — `collect` rejects with a\n * `Cancelled` (see below), which a caller distinguishes from a real failure so\n * it does not show an error for a decision.\n */\n collect(method: Method, tender: Tender, detail?: Detail): Promise<Token>\n\n /** Tear down every element and listener this terminal owns. */\n release(): Promise<void>\n}\n\n/**\n * The buyer dismissed the sheet. NOT an error to show anyone.\n *\n * Every rail signals this differently — Square's web tokenize resolves with\n * `status: 'CANCEL'`, Apple Pay on native calls a cancel callback, Google Pay\n * rejects with its own code — and a caller that cannot tell them apart shows\n * \"payment failed\" to someone who simply changed their mind.\n */\nexport class Cancelled extends Error {\n readonly method: Method\n constructor(method: Method) {\n super(`${method} was cancelled`)\n this.name = 'Cancelled'\n this.method = method\n }\n}\n\n/** True for the one rejection that means \"the buyer said no\", on any platform. */\nexport function cancelled(e: unknown): e is Cancelled {\n return e instanceof Cancelled\n}\n\n/**\n * The rails each platform's Square SDK can even ATTEMPT.\n *\n * This is a fact about the SDKs, not about a merchant account, and the two do not\n * agree: Square's In-App Payments SDK ships no Cash App Pay and no ACH — its\n * whole exported surface is card entry, gift card entry, Apple Pay, Google Pay\n * and buyer verification. Offering either on a phone would be a button that\n * cannot resolve.\n *\n * `offers()` intersects this with what the SDK says it can build, so a rail\n * missing here is absent from the picker rather than present and dead.\n *\n * THIS IS REACH, NOT POLICY, and the difference has already been mistaken once.\n * A host may decline a rail this table lists, for reasons that have nothing to\n * do with tokenizing: hanzoai/pay does not offer ACH on Square, because its\n * top-up endpoint credits the balance as soon as the charge call succeeds while\n * an ACH debit settles days later — so it would credit unsettled money. The SDK\n * reaches ACH perfectly well. Deciding not to use it is the host's call and does\n * not belong here; deleting `ach` below would instead tell every other host that\n * the rail does not exist.\n */\nexport const REACH: Record<'web' | 'native', readonly Method[]> = {\n web: ['card', 'apple_pay', 'google_pay', 'cash_app', 'ach', 'gift'],\n native: ['card', 'apple_pay', 'google_pay', 'gift'],\n}\n","// The card field's colours — the ONE place this package writes a colour down.\n//\n// Everything else on a checkout is styled from the host's own tokens, so a theme\n// change is a class on <html> and no JS is involved. The card fields cannot work\n// that way, and the reason is the same on both platforms for different causes:\n//\n// web Square renders the fields in a CROSS-ORIGIN iframe, which cannot read\n// our custom properties. The only channel is the style object handed to\n// `payments.card({ style })`, and it takes literal values.\n// native Square renders the fields in a NATIVE view. There is no CSS at all;\n// the only channel is `SQIPCardEntry.setIOSCardEntryTheme`, which takes\n// {r,g,b,a} components.\n//\n// So the literals are unavoidable on both. What is avoidable is having them\n// scattered, or drifting from the palette they mirror, or disagreeing between web\n// and native — hence ONE table here, rendered per platform by each backend\n// (`style()` in ./web, `theme()` in ./native). A brand supplies its own table and\n// gets both renderings for free.\n//\n// Every value is an OPAQUE hex. Square validates style values against its own\n// allowlist and rejects what it cannot parse, and an alpha channel over a surface\n// we do not control is a guess about what is behind it anyway. Alpha rungs of a\n// design ladder (white/10, white/40) are therefore composited onto the field's\n// own ground here, which is what the eye sees regardless.\n\n/** The two grounds a checkout can sit on. Dark is the default. */\nexport type Theme = 'dark' | 'light'\n\n// A PALETTE IS ONE SURFACE, and `field` + `text` are the pair that makes it\n// readable. They have forked twice in production, in both directions: once as a\n// white ground under near-white ink (`field: 'transparent'`, so Square's own\n// white showed through), and once as near-black ink placed on this ground on the\n// belief that the ground was still white. Both shipped a card number the customer\n// could not see while typing it. Neither is a judgement call — `legible()` below\n// measures the pair, and the first of those two states measures 1.0 against a 4.5\n// floor. Change a ground and its ink in one edit, and let the check say whether\n// the result can be read.\nexport interface Palette {\n /** The field's own ground. */\n field: string\n /** Hairline at rest. */\n border: string\n /** Focused hairline. Deliberately >= 3:1 on `field`: it IS the focus indicator. */\n borderFocus: string\n /** Typed characters. */\n text: string\n /** Placeholder text. */\n placeholder: string\n /** Validation text, icon and border. One error hue. */\n error: string\n}\n\n// Both columns are @hanzo/design's own values, not new ones. The dark column is\n// the palette `:root` publishes; the light column is what `.light` inverts it to.\n// Composited alphas are noted where the token is a rung rather than a hex.\nexport const PALETTE: Record<Theme, Palette> = {\n dark: {\n field: '#0a0a0a', // --card\n border: '#232323', // --white-10 over --card\n borderFocus: '#787878', // --white-45 over --card — 4.4:1 on #0a0a0a\n text: '#ededed', // --foreground\n placeholder: '#888888', // --muted-foreground\n error: '#fca5a5', // --state-error-text (red-300), legible on black\n },\n light: {\n field: '#ffffff', // --background\n border: '#e5e5e5', // --border (neutral-200)\n borderFocus: '#737373', // --neutral-500 — 4.7:1 on #ffffff\n text: '#0a0a0a', // --foreground\n placeholder: '#525252', // --muted-foreground\n error: '#ef4444', // --state-error (red-500), legible on white\n },\n}\n\n/** `#rrggbb`, lowercase. The only form Square accepts on both platforms. */\nexport const HEX = /^#[0-9a-f]{6}$/\n\n/** Relative luminance, WCAG 2.x. */\nexport function luminance(hex: string): number {\n const c = [1, 3, 5].map((i) => {\n const v = parseInt(hex.slice(i, i + 2), 16) / 255\n return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4\n })\n return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]\n}\n\n/** Contrast ratio between two opaque hexes, 1..21. Order does not matter. */\nexport function ratio(a: string, b: string): number {\n const [x, y] = [luminance(a), luminance(b)].sort((m, n) => n - m)\n return (x + 0.05) / (y + 0.05)\n}\n\n/** One failed contrast pair: what was measured, against what floor. */\nexport interface Fault {\n pair: string\n ratio: number\n floor: number\n}\n\n/**\n * Every contrast pair in a palette that a customer must be able to read, with\n * the WCAG floor each is held to.\n *\n * This is exported rather than kept in a test because a BRAND supplying its own\n * table needs the same check, and the failure it catches is invisible in review:\n * a reviewer looking at a dark mock cannot tell 2.6:1 from 4.4:1, and the focus\n * ring is a WCAG 2.4.11 requirement rather than a preference.\n */\nexport function legible(p: Palette): Fault[] {\n const pairs: Array<[string, string, string, number]> = [\n // The typed card number, at AA for body text.\n ['text on field', p.text, p.field, 4.5],\n // A placeholder that is merely present, not legible, is a label nobody reads.\n ['placeholder on field', p.placeholder, p.field, 4.5],\n // Non-text contrast: this border IS the focus indicator (WCAG 2.4.11).\n ['borderFocus on field', p.borderFocus, p.field, 3],\n // The error hue carries both text and a border, so it is held to the lower\n // non-text floor — it is never the only signal, the border moves too.\n ['error on field', p.error, p.field, 3],\n ]\n return pairs\n .map(([pair, a, b, floor]) => ({ pair, ratio: ratio(a, b), floor }))\n .filter((f) => f.ratio < f.floor)\n}\n"]}
package/dist/index.cjs ADDED
@@ -0,0 +1,65 @@
1
+ 'use strict';
2
+
3
+ var chunk64AUOMEL_cjs = require('./chunk-64AUOMEL.cjs');
4
+
5
+ // src/attempt.ts
6
+ var LIMIT = 45;
7
+ var FLOOR = 8;
8
+ var CEILING = 32;
9
+ function attempt(reserved = 25) {
10
+ const budget = LIMIT - reserved;
11
+ if (budget < FLOOR) {
12
+ throw new Error(
13
+ `a ${reserved}-character wrapper leaves ${budget} for the key; Square allows ${LIMIT} in total`
14
+ );
15
+ }
16
+ const size = Math.min(budget, CEILING);
17
+ const uuid = globalThis.crypto?.randomUUID?.().replace(/-/g, "");
18
+ if (uuid) return uuid.slice(0, size);
19
+ let raw = Date.now().toString(36);
20
+ while (raw.length < CEILING) raw += Math.random().toString(36).slice(2);
21
+ return raw.slice(0, size);
22
+ }
23
+ function clamp(key, reserved = 25) {
24
+ return key.slice(0, Math.max(FLOOR, LIMIT - reserved));
25
+ }
26
+
27
+ Object.defineProperty(exports, "Cancelled", {
28
+ enumerable: true,
29
+ get: function () { return chunk64AUOMEL_cjs.Cancelled; }
30
+ });
31
+ Object.defineProperty(exports, "HEX", {
32
+ enumerable: true,
33
+ get: function () { return chunk64AUOMEL_cjs.HEX; }
34
+ });
35
+ Object.defineProperty(exports, "PALETTE", {
36
+ enumerable: true,
37
+ get: function () { return chunk64AUOMEL_cjs.PALETTE; }
38
+ });
39
+ Object.defineProperty(exports, "REACH", {
40
+ enumerable: true,
41
+ get: function () { return chunk64AUOMEL_cjs.REACH; }
42
+ });
43
+ Object.defineProperty(exports, "cancelled", {
44
+ enumerable: true,
45
+ get: function () { return chunk64AUOMEL_cjs.cancelled; }
46
+ });
47
+ Object.defineProperty(exports, "legible", {
48
+ enumerable: true,
49
+ get: function () { return chunk64AUOMEL_cjs.legible; }
50
+ });
51
+ Object.defineProperty(exports, "luminance", {
52
+ enumerable: true,
53
+ get: function () { return chunk64AUOMEL_cjs.luminance; }
54
+ });
55
+ Object.defineProperty(exports, "ratio", {
56
+ enumerable: true,
57
+ get: function () { return chunk64AUOMEL_cjs.ratio; }
58
+ });
59
+ exports.CEILING = CEILING;
60
+ exports.FLOOR = FLOOR;
61
+ exports.LIMIT = LIMIT;
62
+ exports.attempt = attempt;
63
+ exports.clamp = clamp;
64
+ //# sourceMappingURL=index.cjs.map
65
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/attempt.ts"],"names":[],"mappings":";;;;;AAkBO,IAAM,KAAA,GAAQ;AAUd,IAAM,KAAA,GAAQ;AAWd,IAAM,OAAA,GAAU;AAqBhB,SAAS,OAAA,CAAQ,WAAW,EAAA,EAAY;AAC7C,EAAA,MAAM,SAAS,KAAA,GAAQ,QAAA;AACvB,EAAA,IAAI,SAAS,KAAA,EAAO;AAClB,IAAA,MAAM,IAAI,KAAA;AAAA,MACR,CAAA,EAAA,EAAK,QAAQ,CAAA,0BAAA,EAA6B,MAAM,+BAA+B,KAAK,CAAA,SAAA;AAAA,KACtF;AAAA,EACF;AACA,EAAA,MAAM,IAAA,GAAO,IAAA,CAAK,GAAA,CAAI,MAAA,EAAQ,OAAO,CAAA;AACrC,EAAA,MAAM,OAAO,UAAA,CAAW,MAAA,EAAQ,cAAa,CAAE,OAAA,CAAQ,MAAM,EAAE,CAAA;AAC/D,EAAA,IAAI,IAAA,EAAM,OAAO,IAAA,CAAK,KAAA,CAAM,GAAG,IAAI,CAAA;AAKnC,EAAA,IAAI,GAAA,GAAM,IAAA,CAAK,GAAA,EAAI,CAAE,SAAS,EAAE,CAAA;AAChC,EAAA,OAAO,GAAA,CAAI,MAAA,GAAS,OAAA,EAAS,GAAA,IAAO,IAAA,CAAK,MAAA,EAAO,CAAE,QAAA,CAAS,EAAE,CAAA,CAAE,KAAA,CAAM,CAAC,CAAA;AACtE,EAAA,OAAO,GAAA,CAAI,KAAA,CAAM,CAAA,EAAG,IAAI,CAAA;AAC1B;AAUO,SAAS,KAAA,CAAM,GAAA,EAAa,QAAA,GAAW,EAAA,EAAY;AACxD,EAAA,OAAO,GAAA,CAAI,MAAM,CAAA,EAAG,IAAA,CAAK,IAAI,KAAA,EAAO,KAAA,GAAQ,QAAQ,CAAC,CAAA;AACvD","file":"index.cjs","sourcesContent":["// The idempotency key, and the ruler it answers to.\n//\n// This package never charges anything, so it never sends this header. It mints\n// the key anyway, because the CONSTRAINT is a Square protocol fact and Square\n// protocol facts are what this package exists to hold — and because the shape of\n// the failure is one no caller can debug from where it lands.\n//\n// A key over 45 characters is rejected by Square with VALUE_TOO_LONG, BEFORE a\n// cent moves. hanzoai/pay minted a 36-char UUID and commerce forwarded it\n// wrapped as `<op>:<org>:<key>`, so `topup:hanzo:` + 36 = 49 and EVERY card\n// charge for that org failed. Nothing about that surfaces as \"the key is too\n// long\": the client sent a perfectly ordinary UUID, the failure arrived from a\n// processor two hops away, and it read as \"charge failed\".\n\n/**\n * Square's ceiling on `idempotency_key`, in characters. Not a guideline — it is\n * a hard rejection, and it happens before authorization.\n */\nexport const LIMIT = 45\n\n/**\n * The smallest key worth minting.\n *\n * Keys are namespaced per org by the wrapper, so a collision inside one org\n * would replay ANOTHER buyer's charge — the one failure mode worse than\n * double-charging. Below this there is not enough entropy to rule that out, and\n * silently minting a short key would trade a loud error for a quiet one.\n */\nexport const FLOOR = 8\n\n/**\n * The largest key worth minting: a whole UUID with its hyphens removed.\n *\n * 128 bits already rules out collision by any margin that matters, so a budget\n * bigger than this buys nothing. Naming the ceiling is what stops `attempt()`\n * quietly returning fewer characters than it was asked for — which it did, and\n * which its own test caught: 45 minus a 10-character wrapper is 35, and a\n * stripped UUID is 32.\n */\nexport const CEILING = 32\n\n/**\n * A key identifying ONE payment attempt, sized to survive being wrapped.\n *\n * Hold it across retries — that is the entire point. A retry carrying the same\n * key replays the first result instead of charging again, even a retry that had\n * to re-tokenize a fresh single-use token because the buyer pressed \"start\n * over\". A key minted per REQUEST rather than per ATTEMPT provides no protection\n * whatsoever, and looks identical from here.\n *\n * `reserved` is how many characters the server will prepend. The default 25 is\n * commerce's worst case and yields the 20-char key hanzoai/pay ships:\n * `subscribe:` (10) + an org slug + `:` (1), which leaves room for slugs up to\n * 14 even under the longest op. A host whose wrapper is shorter may pass a\n * smaller number and get a longer key; none of them needs a longer key.\n *\n * Entropy comes from `crypto.randomUUID` where it exists. The fallback is time\n * plus `Math.random`, which is weaker and deliberately not silent about it —\n * see the note there.\n */\nexport function attempt(reserved = 25): string {\n const budget = LIMIT - reserved\n if (budget < FLOOR) {\n throw new Error(\n `a ${reserved}-character wrapper leaves ${budget} for the key; Square allows ${LIMIT} in total`,\n )\n }\n const size = Math.min(budget, CEILING)\n const uuid = globalThis.crypto?.randomUUID?.().replace(/-/g, '')\n if (uuid) return uuid.slice(0, size)\n // No crypto: an older RN runtime without `react-native-get-random-values`, or\n // a non-secure browser context. Draw until the ceiling is covered, because ONE\n // `Math.random().toString(36)` is ~11 characters — a key that is short where\n // it looks long is the failure this whole module is about.\n let raw = Date.now().toString(36)\n while (raw.length < CEILING) raw += Math.random().toString(36).slice(2)\n return raw.slice(0, size)\n}\n\n/**\n * Cut a key that was minted before anyone knew about the ceiling.\n *\n * Shortening a STORED key cannot skip a replay, and that is what makes this safe\n * rather than reckless: Square never accepted the long key, so no charge exists\n * under it to replay. A session holding a 36-character UUID would otherwise\n * re-fail forever, having carefully preserved the exact value that cannot work.\n */\nexport function clamp(key: string, reserved = 25): string {\n return key.slice(0, Math.max(FLOOR, LIMIT - reserved))\n}\n"]}
@@ -0,0 +1,3 @@
1
+ export { type Detail, type Method, type Money, type Tender, type Terminal, type Token, Cancelled, cancelled, REACH, } from './terminal';
2
+ export { type Fault, type Palette, type Theme, HEX, legible, luminance, PALETTE, ratio, } from './palette';
3
+ export { attempt, CEILING, clamp, FLOOR, LIMIT } from './attempt';
package/dist/index.js ADDED
@@ -0,0 +1,27 @@
1
+ export { Cancelled, HEX, PALETTE, REACH, cancelled, legible, luminance, ratio } from './chunk-DXK2QDTX.js';
2
+
3
+ // src/attempt.ts
4
+ var LIMIT = 45;
5
+ var FLOOR = 8;
6
+ var CEILING = 32;
7
+ function attempt(reserved = 25) {
8
+ const budget = LIMIT - reserved;
9
+ if (budget < FLOOR) {
10
+ throw new Error(
11
+ `a ${reserved}-character wrapper leaves ${budget} for the key; Square allows ${LIMIT} in total`
12
+ );
13
+ }
14
+ const size = Math.min(budget, CEILING);
15
+ const uuid = globalThis.crypto?.randomUUID?.().replace(/-/g, "");
16
+ if (uuid) return uuid.slice(0, size);
17
+ let raw = Date.now().toString(36);
18
+ while (raw.length < CEILING) raw += Math.random().toString(36).slice(2);
19
+ return raw.slice(0, size);
20
+ }
21
+ function clamp(key, reserved = 25) {
22
+ return key.slice(0, Math.max(FLOOR, LIMIT - reserved));
23
+ }
24
+
25
+ export { CEILING, FLOOR, LIMIT, attempt, clamp };
26
+ //# sourceMappingURL=index.js.map
27
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/attempt.ts"],"names":[],"mappings":";;;AAkBO,IAAM,KAAA,GAAQ;AAUd,IAAM,KAAA,GAAQ;AAWd,IAAM,OAAA,GAAU;AAqBhB,SAAS,OAAA,CAAQ,WAAW,EAAA,EAAY;AAC7C,EAAA,MAAM,SAAS,KAAA,GAAQ,QAAA;AACvB,EAAA,IAAI,SAAS,KAAA,EAAO;AAClB,IAAA,MAAM,IAAI,KAAA;AAAA,MACR,CAAA,EAAA,EAAK,QAAQ,CAAA,0BAAA,EAA6B,MAAM,+BAA+B,KAAK,CAAA,SAAA;AAAA,KACtF;AAAA,EACF;AACA,EAAA,MAAM,IAAA,GAAO,IAAA,CAAK,GAAA,CAAI,MAAA,EAAQ,OAAO,CAAA;AACrC,EAAA,MAAM,OAAO,UAAA,CAAW,MAAA,EAAQ,cAAa,CAAE,OAAA,CAAQ,MAAM,EAAE,CAAA;AAC/D,EAAA,IAAI,IAAA,EAAM,OAAO,IAAA,CAAK,KAAA,CAAM,GAAG,IAAI,CAAA;AAKnC,EAAA,IAAI,GAAA,GAAM,IAAA,CAAK,GAAA,EAAI,CAAE,SAAS,EAAE,CAAA;AAChC,EAAA,OAAO,GAAA,CAAI,MAAA,GAAS,OAAA,EAAS,GAAA,IAAO,IAAA,CAAK,MAAA,EAAO,CAAE,QAAA,CAAS,EAAE,CAAA,CAAE,KAAA,CAAM,CAAC,CAAA;AACtE,EAAA,OAAO,GAAA,CAAI,KAAA,CAAM,CAAA,EAAG,IAAI,CAAA;AAC1B;AAUO,SAAS,KAAA,CAAM,GAAA,EAAa,QAAA,GAAW,EAAA,EAAY;AACxD,EAAA,OAAO,GAAA,CAAI,MAAM,CAAA,EAAG,IAAA,CAAK,IAAI,KAAA,EAAO,KAAA,GAAQ,QAAQ,CAAC,CAAA;AACvD","file":"index.js","sourcesContent":["// The idempotency key, and the ruler it answers to.\n//\n// This package never charges anything, so it never sends this header. It mints\n// the key anyway, because the CONSTRAINT is a Square protocol fact and Square\n// protocol facts are what this package exists to hold — and because the shape of\n// the failure is one no caller can debug from where it lands.\n//\n// A key over 45 characters is rejected by Square with VALUE_TOO_LONG, BEFORE a\n// cent moves. hanzoai/pay minted a 36-char UUID and commerce forwarded it\n// wrapped as `<op>:<org>:<key>`, so `topup:hanzo:` + 36 = 49 and EVERY card\n// charge for that org failed. Nothing about that surfaces as \"the key is too\n// long\": the client sent a perfectly ordinary UUID, the failure arrived from a\n// processor two hops away, and it read as \"charge failed\".\n\n/**\n * Square's ceiling on `idempotency_key`, in characters. Not a guideline — it is\n * a hard rejection, and it happens before authorization.\n */\nexport const LIMIT = 45\n\n/**\n * The smallest key worth minting.\n *\n * Keys are namespaced per org by the wrapper, so a collision inside one org\n * would replay ANOTHER buyer's charge — the one failure mode worse than\n * double-charging. Below this there is not enough entropy to rule that out, and\n * silently minting a short key would trade a loud error for a quiet one.\n */\nexport const FLOOR = 8\n\n/**\n * The largest key worth minting: a whole UUID with its hyphens removed.\n *\n * 128 bits already rules out collision by any margin that matters, so a budget\n * bigger than this buys nothing. Naming the ceiling is what stops `attempt()`\n * quietly returning fewer characters than it was asked for — which it did, and\n * which its own test caught: 45 minus a 10-character wrapper is 35, and a\n * stripped UUID is 32.\n */\nexport const CEILING = 32\n\n/**\n * A key identifying ONE payment attempt, sized to survive being wrapped.\n *\n * Hold it across retries — that is the entire point. A retry carrying the same\n * key replays the first result instead of charging again, even a retry that had\n * to re-tokenize a fresh single-use token because the buyer pressed \"start\n * over\". A key minted per REQUEST rather than per ATTEMPT provides no protection\n * whatsoever, and looks identical from here.\n *\n * `reserved` is how many characters the server will prepend. The default 25 is\n * commerce's worst case and yields the 20-char key hanzoai/pay ships:\n * `subscribe:` (10) + an org slug + `:` (1), which leaves room for slugs up to\n * 14 even under the longest op. A host whose wrapper is shorter may pass a\n * smaller number and get a longer key; none of them needs a longer key.\n *\n * Entropy comes from `crypto.randomUUID` where it exists. The fallback is time\n * plus `Math.random`, which is weaker and deliberately not silent about it —\n * see the note there.\n */\nexport function attempt(reserved = 25): string {\n const budget = LIMIT - reserved\n if (budget < FLOOR) {\n throw new Error(\n `a ${reserved}-character wrapper leaves ${budget} for the key; Square allows ${LIMIT} in total`,\n )\n }\n const size = Math.min(budget, CEILING)\n const uuid = globalThis.crypto?.randomUUID?.().replace(/-/g, '')\n if (uuid) return uuid.slice(0, size)\n // No crypto: an older RN runtime without `react-native-get-random-values`, or\n // a non-secure browser context. Draw until the ceiling is covered, because ONE\n // `Math.random().toString(36)` is ~11 characters — a key that is short where\n // it looks long is the failure this whole module is about.\n let raw = Date.now().toString(36)\n while (raw.length < CEILING) raw += Math.random().toString(36).slice(2)\n return raw.slice(0, size)\n}\n\n/**\n * Cut a key that was minted before anyone knew about the ceiling.\n *\n * Shortening a STORED key cannot skip a replay, and that is what makes this safe\n * rather than reckless: Square never accepted the long key, so no charge exists\n * under it to replay. A session holding a 36-character UUID would otherwise\n * re-fail forever, having carefully preserved the exact value that cannot work.\n */\nexport function clamp(key: string, reserved = 25): string {\n return key.slice(0, Math.max(FLOOR, LIMIT - reserved))\n}\n"]}