Skip to content

Commit 11d4601

Browse files
Paulclaude
andcommitted
Add docs/ with setup guides for all bridges, destinations, and server
- docs/bridges/{apple,google,stripe,x402}.md — config tables, env detection, notification type mappings, store dashboard setup steps, error codes - docs/destinations/{starfish,anchor}.md — full config, DB schema (Anchor), document schema (Starfish), component breakdown, RLS notes - docs/server.md — defineConfig, mode enforcement, hooks, dedup, rate limiter, mint retry, HTTP endpoints - docs/namespaced-server.md — multi-namespace routing, per-namespace mode isolation, shared vs scoped resources, Express integration example - README.md: add Documentation section linking to docs/ Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 5b84808 commit 11d4601

10 files changed

Lines changed: 1303 additions & 0 deletions

File tree

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,16 @@ Custom Store ─────┘
3939

4040
---
4141

42+
## Documentation
43+
44+
Full setup guides for every bridge, destination, and server option are in [`docs/`](docs/README.md):
45+
46+
- **Bridges:** [Apple](docs/bridges/apple.md) · [Google](docs/bridges/google.md) · [Stripe](docs/bridges/stripe.md) · [x402](docs/bridges/x402.md)
47+
- **Destinations:** [Starfish](docs/destinations/starfish.md) · [Anchor / Supabase](docs/destinations/anchor.md)
48+
- **Server:** [defineConfig & createServer](docs/server.md) · [Namespaced server](docs/namespaced-server.md)
49+
50+
---
51+
4252
## Quick Start
4353

4454
```bash

docs/README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Doubloon Docs
2+
3+
## Bridges
4+
5+
Payment store integrations that translate store notifications into normalized mint/revoke instructions.
6+
7+
- [Apple App Store](bridges/apple.md) — App Store Server Notifications V2 (JWS-signed)
8+
- [Google Play](bridges/google.md) — Real-Time Developer Notifications via Pub/Sub
9+
- [Stripe](bridges/stripe.md) — Subscription lifecycle + charge refunds
10+
- [x402](bridges/x402.md) — HTTP 402 Payment Required protocol
11+
12+
## Destinations
13+
14+
Entitlement storage backends.
15+
16+
- [Starfish](destinations/starfish.md) — Append-only hash-chained HTTP store
17+
- [Anchor](destinations/anchor.md) — Supabase / Postgres
18+
19+
## Server
20+
21+
- [Server](server.md)`defineConfig`, `createServer`, mode enforcement, hooks, dedup, rate limiter
22+
- [Namespaced Server](namespaced-server.md)`createNamespacedServer`, multi-app / multi-environment routing

docs/bridges/apple.md

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
# Apple App Store Bridge
2+
3+
Package: `@drakkar.software/doubloon-bridge-apple`
4+
5+
Handles Apple App Store Server Notifications V2. Incoming webhooks carry a JWS-signed payload; the bridge verifies the certificate chain and signature before trusting any content.
6+
7+
---
8+
9+
## Installation
10+
11+
```bash
12+
pnpm add @drakkar.software/doubloon-bridge-apple
13+
```
14+
15+
---
16+
17+
## Configuration
18+
19+
```ts
20+
import { AppleBridge } from '@drakkar.software/doubloon-bridge-apple';
21+
22+
const apple = new AppleBridge({
23+
bundleId: 'com.example.app',
24+
issuerId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
25+
keyId: 'XXXXXXXXXX',
26+
privateKey: process.env.APPLE_PRIVATE_KEY!, // PEM string
27+
productResolver,
28+
walletResolver,
29+
});
30+
```
31+
32+
### `AppleBridgeConfig`
33+
34+
| Field | Type | Required | Description |
35+
|---|---|---|---|
36+
| `bundleId` | `string` || App bundle ID (e.g. `com.example.app`). Validated against the signed payload's `data.bundleId`. |
37+
| `issuerId` | `string` || Issuer ID from App Store Connect → Keys. |
38+
| `keyId` | `string` || Key ID from App Store Connect → Keys. |
39+
| `privateKey` | `string` || PEM-encoded ES256 private key (`.p8` file contents). |
40+
| `rootCertificates` | `Buffer[]` | | Apple root CA certificates for chain verification. If omitted, the built-in Apple Root CA G3 is used for sandbox; production chain terminates at Apple's well-known root. |
41+
| `productResolver` | `StoreProductResolver` || Maps Apple product IDs to on-chain product IDs. See [Product Resolver](#product-resolver). |
42+
| `walletResolver` | `WalletResolver` || Resolves user wallet from `appAccountToken` or `originalTransactionId`. See [Wallet Resolver](#wallet-resolver). |
43+
| `logger` | `Logger` | | Optional structured logger. |
44+
| `environment` | `'production' \| 'sandbox'` | | **Deprecated.** Environment is now derived from the signed JWS payload's `data.environment` field. This field is ignored. |
45+
| `appAppleId` | `number` | | Reserved for future use. |
46+
47+
---
48+
49+
## Environment Detection
50+
51+
Environment (`'production'` or `'sandbox'`) is read from the signed JWS payload:
52+
53+
```
54+
payload.data.environment → "Sandbox" | "Production"
55+
```
56+
57+
The bridge normalizes it to lowercase. For plain JSON bodies (non-JWS), environment defaults to `'production'`.
58+
59+
> Configure a server `mode` to reject events from the wrong environment. See [server docs](../server.md#mode).
60+
61+
---
62+
63+
## Product Resolver
64+
65+
```ts
66+
interface StoreProductResolver {
67+
resolveProductId(store: string, storeSku: string): Promise<string | null>;
68+
resolveStoreSku(store: string, productId: string): Promise<string[]>;
69+
}
70+
```
71+
72+
`store` is `'apple'`. `storeSku` is the Apple `productId` string (e.g. `com.example.pro_monthly`). Return the 64-char hex on-chain product ID, or `null` if unmapped (throws `PRODUCT_NOT_MAPPED`).
73+
74+
---
75+
76+
## Wallet Resolver
77+
78+
```ts
79+
interface WalletResolver {
80+
resolveWallet(store: string, storeUserId: string): Promise<string | null>;
81+
linkWallet(store: string, storeUserId: string, wallet: string): Promise<void>;
82+
}
83+
```
84+
85+
Resolution order:
86+
1. `tx.appAccountToken` — UUID set by your app at purchase time via `Product.purchase(options: .appAccountToken(...))`.
87+
2. `tx.originalTransactionId` — fallback for transactions without an account token.
88+
89+
Return `null` to throw `WALLET_NOT_LINKED`. Accepted wallet formats: Solana base58 (32–44 chars) or EVM `0x...` (42 chars).
90+
91+
---
92+
93+
## Notification Types
94+
95+
| Apple type / subtype | Normalized type |
96+
|---|---|
97+
| `SUBSCRIBED` | `initial_purchase` |
98+
| `DID_RENEW` | `renewal` |
99+
| `DID_RENEW` + `BILLING_RECOVERY` | `billing_recovery` |
100+
| `EXPIRED` | `expiration` |
101+
| `REVOKE` | `revocation` |
102+
| `REFUND` | `refund` |
103+
| `DID_CHANGE_RENEWAL_STATUS` (cancel) | `cancellation` |
104+
| `DID_CHANGE_RENEWAL_STATUS` (uncancel) | `uncancellation` |
105+
| `GRACE_PERIOD_EXPIRED` | `grace_period_start` |
106+
| `DID_FAIL_TO_RENEW` | `billing_retry_start` |
107+
| `PRICE_INCREASE` | `price_increase_consent` |
108+
| `OFFER_REDEEMED` | `offer_redeemed` |
109+
| `DID_CHANGE_RENEWAL_PREF` | `plan_change` |
110+
| `DID_PAUSE` | `pause` |
111+
| `DID_RESUME` | `resume` |
112+
| `TEST` | `test` |
113+
114+
**Mint instruction** produced for: `initial_purchase`, `renewal`, `billing_recovery`, `offer_redeemed`, `plan_change`, `resume`.
115+
**Revoke instruction** produced for: `expiration`, `refund`, `revocation`.
116+
**No instruction** (pass-through) for: `cancellation`, `uncancellation`, `grace_period_start`, `billing_retry_start`, `price_increase_consent`, `pause`, `test`.
117+
118+
---
119+
120+
## Apple App Store Setup
121+
122+
1. In **App Store Connect → Users & Access → Integrations → App Store Server Notifications**, set the production URL to `https://your-server.com/webhook` (or your namespace path).
123+
2. Generate an **API key** (Issuer ID + Key ID + .p8 file) for receipt validation if needed.
124+
3. Enable **App Account Token** in your iOS/macOS app so the bridge can link purchases to wallets without a database lookup.
125+
126+
---
127+
128+
## Error Codes
129+
130+
| Code | Cause |
131+
|---|---|
132+
| `INVALID_RECEIPT` | Malformed body or missing `notificationType`. |
133+
| `INVALID_SIGNATURE` | JWS certificate chain broken, root CA mismatch, or signature invalid. |
134+
| `PRODUCT_NOT_MAPPED` | `productResolver.resolveProductId` returned `null`. |
135+
| `WALLET_NOT_LINKED` | No wallet resolved, or address format invalid. |

docs/bridges/google.md

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
# Google Play Bridge
2+
3+
Package: `@drakkar.software/doubloon-bridge-google`
4+
5+
Handles Google Play Real-Time Developer Notifications (RTDN) delivered via Google Cloud Pub/Sub push subscriptions.
6+
7+
---
8+
9+
## Installation
10+
11+
```bash
12+
pnpm add @drakkar.software/doubloon-bridge-google
13+
```
14+
15+
---
16+
17+
## Configuration
18+
19+
```ts
20+
import { GoogleBridge } from '@drakkar.software/doubloon-bridge-google';
21+
22+
const google = new GoogleBridge({
23+
packageName: 'com.example.app',
24+
serviceAccountKey: process.env.GOOGLE_SERVICE_ACCOUNT_KEY!, // JSON string
25+
productResolver,
26+
walletResolver,
27+
});
28+
```
29+
30+
### `GoogleBridgeConfig`
31+
32+
| Field | Type | Required | Description |
33+
|---|---|---|---|
34+
| `packageName` | `string` || Android application package name (e.g. `com.example.app`). Validated against incoming RTDNs. |
35+
| `serviceAccountKey` | `string` || Google service account credentials JSON as a string. Must have `androidpublisher` API access for receipt verification calls. Pass `'{}'` in tests. |
36+
| `productResolver` | `StoreProductResolver` || Maps Google subscription IDs / SKUs to on-chain product IDs. See [Product Resolver](#product-resolver). |
37+
| `walletResolver` | `WalletResolver` || Resolves user wallet from a purchase token. See [Wallet Resolver](#wallet-resolver). |
38+
| `environment` | `'production' \| 'sandbox'` | | Environment override. Defaults to `'production'`. Google's RTDN schema carries no per-event environment signal — use this flag (or a separate bridge instance) for test traffic. |
39+
| `logger` | `Logger` | | Optional structured logger. |
40+
41+
---
42+
43+
## Environment Detection
44+
45+
Google RTDNs do **not** carry a per-event environment field. The bridge uses `config.environment ?? 'production'`. The only automatic environment override is the top-level `testNotification` object — those always resolve to `'sandbox'`.
46+
47+
Recommended practice for isolating sandbox traffic: configure a **separate Pub/Sub topic** for test purchases and point it at a bridge instance with `environment: 'sandbox'`. With per-namespace servers you can do:
48+
49+
```ts
50+
const server = createNamespacedServer({
51+
namespaces: {
52+
'app-prod': { bridges: { google: new GoogleBridge({ environment: 'production', ... }) }, mode: 'production', ... },
53+
'app-test': { bridges: { google: new GoogleBridge({ environment: 'sandbox', ... }) }, mode: 'sandbox', ... },
54+
},
55+
...
56+
});
57+
```
58+
59+
---
60+
61+
## Product Resolver
62+
63+
```ts
64+
interface StoreProductResolver {
65+
resolveProductId(store: string, storeSku: string): Promise<string | null>;
66+
resolveStoreSku(store: string, productId: string): Promise<string[]>;
67+
}
68+
```
69+
70+
`store` is `'google'`. For subscription notifications, `storeSku` is `subscriptionNotification.subscriptionId`. For one-time products, it is `oneTimeProductNotification.sku`. Return the 64-char hex on-chain product ID, or `null` (throws `PRODUCT_NOT_MAPPED`).
71+
72+
---
73+
74+
## Wallet Resolver
75+
76+
```ts
77+
interface WalletResolver {
78+
resolveWallet(store: string, storeUserId: string): Promise<string | null>;
79+
linkWallet(store: string, storeUserId: string, wallet: string): Promise<void>;
80+
}
81+
```
82+
83+
`storeUserId` is the Pub/Sub message `purchaseToken`. You must store the mapping between your user's wallet and the purchase token at checkout time (e.g., via `obfuscatedExternalAccountId` in Google's billing API).
84+
85+
Return `null` to throw `WALLET_NOT_LINKED`. Accepted wallet formats: Solana base58 (32–44 chars) or EVM `0x...` (42 chars).
86+
87+
---
88+
89+
## Notification Types
90+
91+
### Subscription notifications (`subscriptionNotification`)
92+
93+
| `notificationType` | Normalized type |
94+
|---|---|
95+
| 1 — RECOVERED | `billing_recovery` |
96+
| 2 — RENEWED | `renewal` |
97+
| 3 — CANCELED | `cancellation` |
98+
| 4 — PURCHASED | `initial_purchase` |
99+
| 5 — ON_HOLD | `grace_period_start` |
100+
| 6 — IN_GRACE_PERIOD | `grace_period_start` |
101+
| 7 — RESTARTED | `resume` |
102+
| 8 — PRICE_CHANGE_CONFIRMED | `price_increase_consent` |
103+
| 9 — DEFERRED | `renewal` |
104+
| 10 — PAUSED | `pause` |
105+
| 11 — PAUSE_SCHEDULE_CHANGED | `pause` |
106+
| 12 — REVOKED | `revocation` |
107+
| 13 — EXPIRED | `expiration` |
108+
109+
### One-time product notifications (`oneTimeProductNotification`)
110+
111+
| `notificationType` | Normalized type |
112+
|---|---|
113+
| 1 — ONE_TIME_PRODUCT_PURCHASED | `initial_purchase` |
114+
| 2 — ONE_TIME_PRODUCT_CANCELED | `cancellation` |
115+
116+
### Test notifications (`testNotification`)
117+
118+
Always normalized to `'test'`, `instruction: null`, `requiresAcknowledgment: false`.
119+
120+
---
121+
122+
**Mint instruction** produced for: `initial_purchase`, `renewal`, `billing_recovery`, `resume`.
123+
**Revoke instruction** produced for: `revocation`, `expiration`.
124+
**No instruction** for: `cancellation`, `grace_period_start`, `billing_retry_start`, `price_increase_consent`, `pause`, `test`.
125+
126+
`requiresAcknowledgment: true` only for `initial_purchase` subscription events. Deadline: 3 days from store timestamp.
127+
128+
---
129+
130+
## Google Cloud Pub/Sub Setup
131+
132+
1. In **Google Play Console → Monetization → Real-time developer notifications**, create a Cloud Pub/Sub topic and point it at your endpoint.
133+
2. Create a **push subscription** on that topic with the URL `https://your-server.com/webhook` (or your namespace path).
134+
3. The Pub/Sub push delivers a JSON envelope:
135+
```json
136+
{
137+
"message": {
138+
"data": "<base64-encoded RTDN JSON>",
139+
"messageId": "...",
140+
"publishTime": "..."
141+
},
142+
"subscription": "..."
143+
}
144+
```
145+
Your HTTP server must base64-decode `message.data` and pass the result as the request body to the bridge.
146+
4. Create a **service account** with the `Android Publisher` role and download its JSON key. Pass the JSON string as `serviceAccountKey`.
147+
148+
---
149+
150+
## Error Codes
151+
152+
| Code | Cause |
153+
|---|---|
154+
| `INVALID_RECEIPT` | Malformed body, missing `packageName`, or no recognized notification type. |
155+
| `PRODUCT_NOT_MAPPED` | `productResolver.resolveProductId` returned `null`. |
156+
| `WALLET_NOT_LINKED` | No wallet resolved, or address format invalid. |

0 commit comments

Comments
 (0)