mppx 0.4.1 → 0.4.3

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.
Files changed (63) hide show
  1. package/CHANGELOG.md +266 -0
  2. package/README.md +30 -6
  3. package/dist/bin.js +2 -2
  4. package/dist/bin.js.map +1 -1
  5. package/dist/cli/account.d.ts +53 -0
  6. package/dist/cli/account.d.ts.map +1 -0
  7. package/dist/cli/account.js +156 -0
  8. package/dist/cli/account.js.map +1 -0
  9. package/dist/{cli.d.ts → cli/cli.d.ts} +4 -3
  10. package/dist/cli/cli.d.ts.map +1 -0
  11. package/dist/cli/cli.js +852 -0
  12. package/dist/cli/cli.js.map +1 -0
  13. package/dist/cli/config.d.ts +39 -0
  14. package/dist/cli/config.d.ts.map +1 -0
  15. package/dist/cli/config.js +30 -0
  16. package/dist/cli/config.js.map +1 -0
  17. package/dist/cli/internal.d.ts +16 -0
  18. package/dist/cli/internal.d.ts.map +1 -0
  19. package/dist/cli/internal.js +58 -0
  20. package/dist/cli/internal.js.map +1 -0
  21. package/dist/cli/plugins/index.d.ts +4 -0
  22. package/dist/cli/plugins/index.d.ts.map +1 -0
  23. package/dist/cli/plugins/index.js +4 -0
  24. package/dist/cli/plugins/index.js.map +1 -0
  25. package/dist/cli/plugins/plugin.d.ts +68 -0
  26. package/dist/cli/plugins/plugin.d.ts.map +1 -0
  27. package/dist/cli/plugins/plugin.js +4 -0
  28. package/dist/cli/plugins/plugin.js.map +1 -0
  29. package/dist/cli/plugins/stripe.d.ts +2 -0
  30. package/dist/cli/plugins/stripe.d.ts.map +1 -0
  31. package/dist/cli/plugins/stripe.js +118 -0
  32. package/dist/cli/plugins/stripe.js.map +1 -0
  33. package/dist/cli/plugins/tempo.d.ts +11 -0
  34. package/dist/cli/plugins/tempo.d.ts.map +1 -0
  35. package/dist/cli/plugins/tempo.js +706 -0
  36. package/dist/cli/plugins/tempo.js.map +1 -0
  37. package/dist/cli/utils.d.ts +93 -0
  38. package/dist/cli/utils.d.ts.map +1 -0
  39. package/dist/cli/utils.js +274 -0
  40. package/dist/cli/utils.js.map +1 -0
  41. package/dist/tempo/client/Methods.d.ts +1 -1
  42. package/dist/tempo/client/Session.d.ts +2 -2
  43. package/dist/tempo/internal/defaults.d.ts +1 -1
  44. package/dist/tempo/internal/defaults.js +1 -1
  45. package/package.json +12 -1
  46. package/src/bin.ts +2 -2
  47. package/src/cli/account.ts +157 -0
  48. package/src/{cli.test.ts → cli/cli.test.ts} +107 -51
  49. package/src/cli/cli.ts +907 -0
  50. package/src/cli/config.test.ts +82 -0
  51. package/src/cli/config.ts +44 -0
  52. package/src/cli/internal.ts +72 -0
  53. package/src/cli/plugins/index.ts +3 -0
  54. package/src/cli/plugins/plugin.ts +73 -0
  55. package/src/cli/plugins/stripe.ts +143 -0
  56. package/src/cli/plugins/tempo.ts +842 -0
  57. package/src/cli/utils.ts +336 -0
  58. package/src/tempo/internal/defaults.test.ts +1 -1
  59. package/src/tempo/internal/defaults.ts +1 -1
  60. package/dist/cli.d.ts.map +0 -1
  61. package/dist/cli.js +0 -1992
  62. package/dist/cli.js.map +0 -1
  63. package/src/cli.ts +0 -2178
@@ -0,0 +1,82 @@
1
+ import * as fs from 'node:fs'
2
+ import * as os from 'node:os'
3
+ import * as path from 'node:path'
4
+ import { defineConfig } from './config.js'
5
+ import { loadConfig } from './internal.js'
6
+
7
+ let tmpDir: string
8
+
9
+ beforeEach(() => {
10
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mppx-config-test-'))
11
+ vi.stubEnv('MPPX_CONFIG', '')
12
+ })
13
+
14
+ afterEach(() => {
15
+ vi.unstubAllEnvs()
16
+ fs.rmSync(tmpDir, { recursive: true, force: true })
17
+ })
18
+
19
+ describe('defineConfig', () => {
20
+ test('returns the config object as-is', () => {
21
+ const config = defineConfig({ methods: [] })
22
+ expect(config).toEqual({ methods: [] })
23
+ })
24
+ })
25
+
26
+ describe('loadConfig', () => {
27
+ test('returns undefined when no config file exists', async () => {
28
+ vi.spyOn(process, 'cwd').mockReturnValue(tmpDir)
29
+ const config = await loadConfig()
30
+ expect(config).toBeUndefined()
31
+ vi.mocked(process.cwd).mockRestore()
32
+ })
33
+
34
+ test('loads config from MPPX_CONFIG env var', async () => {
35
+ const configPath = path.join(tmpDir, 'custom.mjs')
36
+ fs.writeFileSync(configPath, 'export default { methods: [] }')
37
+ vi.stubEnv('MPPX_CONFIG', configPath)
38
+ const result = await loadConfig()
39
+ expect(result?.config).toEqual({ methods: [] })
40
+ expect(result?.path).toBe(configPath)
41
+ })
42
+
43
+ test('returns undefined when MPPX_CONFIG points to nonexistent file', async () => {
44
+ vi.stubEnv('MPPX_CONFIG', path.join(tmpDir, 'nonexistent.ts'))
45
+ const config = await loadConfig()
46
+ expect(config).toBeUndefined()
47
+ })
48
+
49
+ test('loads mppx.config.mjs from cwd', async () => {
50
+ const configPath = path.join(tmpDir, 'mppx.config.mjs')
51
+ fs.writeFileSync(configPath, 'export default { methods: [] }')
52
+ vi.spyOn(process, 'cwd').mockReturnValue(tmpDir)
53
+ const result = await loadConfig()
54
+ expect(result?.config).toEqual({ methods: [] })
55
+ expect(result?.path).toBe(configPath)
56
+ vi.mocked(process.cwd).mockRestore()
57
+ })
58
+
59
+ test('walks up from cwd to find config', async () => {
60
+ const nested = path.join(tmpDir, 'a', 'b', 'c')
61
+ fs.mkdirSync(nested, { recursive: true })
62
+ const configPath = path.join(tmpDir, 'mppx.config.mjs')
63
+ fs.writeFileSync(configPath, 'export default { methods: [] }')
64
+ vi.spyOn(process, 'cwd').mockReturnValue(nested)
65
+ const result = await loadConfig()
66
+ expect(result?.config).toEqual({ methods: [] })
67
+ expect(result?.path).toBe(configPath)
68
+ vi.mocked(process.cwd).mockRestore()
69
+ })
70
+
71
+ test('MPPX_CONFIG takes priority over cwd config', async () => {
72
+ fs.writeFileSync(path.join(tmpDir, 'mppx.config.mjs'), 'export default { methods: ["cwd"] }')
73
+ const envConfig = path.join(tmpDir, 'env.mjs')
74
+ fs.writeFileSync(envConfig, 'export default { methods: ["env"] }')
75
+ vi.stubEnv('MPPX_CONFIG', envConfig)
76
+ vi.spyOn(process, 'cwd').mockReturnValue(tmpDir)
77
+ const result = await loadConfig()
78
+ expect(result?.config).toEqual({ methods: ['env'] })
79
+ expect(result?.path).toBe(envConfig)
80
+ vi.mocked(process.cwd).mockRestore()
81
+ })
82
+ })
@@ -0,0 +1,44 @@
1
+ export { resolveAccount } from './account.js'
2
+
3
+ import type * as Mppx from '../client/Mppx.js'
4
+ import type { Plugin } from './plugins/plugin.js'
5
+
6
+ /**
7
+ * Define mppx configuration file
8
+ *
9
+ * @example Add plugins for more custom logging/handling (default: [stripe(), tempo()])
10
+ * ```ts
11
+ * // mppx.config.ts
12
+ * import { defineConfig } from 'mppx/cli'
13
+ * import { tempo } from 'mppx/cli/plugins'
14
+ *
15
+ * export default defineConfig({
16
+ * plugins: [tempo()],
17
+ * })
18
+ * ```
19
+ *
20
+ * @example Add client methods to extend mppx support (e.g. third-party mppx packages)
21
+ * ```ts
22
+ * // mppx.config.ts
23
+ * import { defineConfig, resolveAccount } from 'mppx/cli'
24
+ * import { tempo } from 'mppx/client'
25
+ *
26
+ * export default defineConfig({
27
+ * methods: [tempo({ account: await resolveAccount() })],
28
+ * })
29
+ * ```
30
+ */
31
+ export function defineConfig(config: defineConfig.Config): defineConfig.Config {
32
+ return config
33
+ }
34
+
35
+ export declare namespace defineConfig {
36
+ type Config = {
37
+ /** Array of methods to use. */
38
+ methods?: Mppx.create.Config['methods'] | undefined
39
+ /** Array of plugins to use. */
40
+ plugins?: Plugin[] | undefined
41
+ }
42
+ }
43
+
44
+ export type Config = defineConfig.Config
@@ -0,0 +1,72 @@
1
+ import * as fs from 'node:fs'
2
+ import * as path from 'node:path'
3
+ import type * as Challenge from '../Challenge.js'
4
+ import type * as Method from '../Method.js'
5
+ import type { Config } from './config.js'
6
+ import { stripe as stripePlugin, tempo as tempoPlugin } from './plugins/index.js'
7
+ import type { Plugin } from './plugins/plugin.js'
8
+
9
+ const builtinPlugins: Plugin[] = [tempoPlugin(), stripePlugin()]
10
+
11
+ export function resolvePlugin(
12
+ challenge: Challenge.Challenge,
13
+ config?: { plugins?: Plugin[] | undefined; methods?: any },
14
+ ): { plugin?: Plugin | undefined; method?: Method.AnyClient | undefined } {
15
+ const configPlugin = config?.plugins?.find((p) => p.method === challenge.method)
16
+ if (configPlugin) return { plugin: configPlugin }
17
+
18
+ const builtin = builtinPlugins.find((p) => p.method === challenge.method)
19
+ if (builtin) return { plugin: builtin }
20
+
21
+ const configMethods = config?.methods?.flat() as Method.AnyClient[] | undefined
22
+ const matched = configMethods?.find(
23
+ (m) => m.name === challenge.method && m.intent === challenge.intent,
24
+ )
25
+ if (matched) return { method: matched }
26
+
27
+ return {}
28
+ }
29
+
30
+ const CONFIG_NAMES = ['mppx.config.ts', 'mppx.config.js', 'mppx.config.mjs'] as const
31
+
32
+ export async function loadConfig(
33
+ configFile?: string | undefined,
34
+ ): Promise<{ config: Config; path: string } | undefined> {
35
+ const configPath = resolveConfigPath(configFile)
36
+ if (!configPath) return undefined
37
+ const mod = await import(configPath)
38
+ return { config: (mod.default ?? mod) as Config, path: configPath }
39
+ }
40
+
41
+ function resolveConfigPath(configFile?: string | undefined): string | undefined {
42
+ // 0. Explicit --config flag
43
+ if (configFile) {
44
+ const resolved = path.resolve(configFile)
45
+ if (fs.existsSync(resolved)) return resolved
46
+ return undefined
47
+ }
48
+
49
+ // 1. Explicit env var
50
+ const envPath = process.env.MPPX_CONFIG?.trim()
51
+ if (envPath) {
52
+ const resolved = path.resolve(envPath)
53
+ if (fs.existsSync(resolved)) return resolved
54
+ return undefined
55
+ }
56
+
57
+ // 2. Walk up from cwd, stopping at project root
58
+ let dir = process.cwd()
59
+ while (true) {
60
+ for (const name of CONFIG_NAMES) {
61
+ const candidate = path.join(dir, name)
62
+ if (fs.existsSync(candidate)) return candidate
63
+ }
64
+ const isProjectRoot =
65
+ fs.existsSync(path.join(dir, 'package.json')) || fs.existsSync(path.join(dir, '.git'))
66
+ const parent = path.dirname(dir)
67
+ if (isProjectRoot || parent === dir) break
68
+ dir = parent
69
+ }
70
+
71
+ return undefined
72
+ }
@@ -0,0 +1,3 @@
1
+ export { createPlugin, type Plugin } from './plugin.js'
2
+ export { stripe } from './stripe.js'
3
+ export { tempo } from './tempo.js'
@@ -0,0 +1,73 @@
1
+ import type * as Challenge from '../../Challenge.js'
2
+ import type * as Method from '../../Method.js'
3
+
4
+ export function createPlugin(plugin: Plugin): Plugin {
5
+ return plugin
6
+ }
7
+
8
+ export interface Plugin {
9
+ /** Payment method name (e.g., 'tempo', 'stripe') */
10
+ method: string
11
+
12
+ /**
13
+ * Resolve account, client, and display info for a challenge.
14
+ * Returns methods for credential creation.
15
+ */
16
+ setup(ctx: {
17
+ challenge: Challenge.Challenge
18
+ options: { account?: string | undefined; rpcUrl?: string | undefined }
19
+ methodOpts: Record<string, string>
20
+ }): Promise<{
21
+ /** Token symbol for display (e.g., 'PathUSD', 'USD') */
22
+ tokenSymbol: string
23
+ /** Token decimals for display */
24
+ tokenDecimals: number
25
+ /** Block explorer URL for links */
26
+ explorerUrl?: string | undefined
27
+ /** Client methods for credential creation */
28
+ methods: Method.AnyClient[]
29
+ /** Optional context to pass to createCredential */
30
+ credentialContext?: unknown
31
+ /** Override credential creation entirely (e.g., delegating to an external CLI) */
32
+ createCredential?: ((response: Response) => Promise<string>) | undefined
33
+ }>
34
+
35
+ /**
36
+ * Modify the credential request before sending.
37
+ * Called after credential creation, before the fetch with Authorization header.
38
+ * Plugins can add headers (e.g., Accept: text/event-stream for sessions).
39
+ */
40
+ prepareCredentialRequest?(ctx: {
41
+ challenge: Challenge.Challenge
42
+ credential: string
43
+ headers: Record<string, string>
44
+ }): void
45
+
46
+ /**
47
+ * Handle the full post-credential response lifecycle.
48
+ * Return `true` if fully handled (caller skips default body printing).
49
+ * Return `false` or leave unimplemented for default behavior (print body).
50
+ */
51
+ handleResponse?(ctx: ResponseContext): Promise<boolean>
52
+
53
+ /** Format a receipt field for display. Return undefined to use default formatting. */
54
+ formatReceiptField?(key: string, value: unknown): string | undefined
55
+ }
56
+
57
+ /** Context passed to handleResponse */
58
+ export interface ResponseContext {
59
+ challenge: Challenge.Challenge
60
+ credential: string
61
+ response: Response
62
+ fetchUrl: string
63
+ fetchInit: RequestInit
64
+ silent: boolean
65
+ verbose: number
66
+ confirmEnabled: boolean
67
+ confirm: (msg: string, defaultYes?: boolean) => Promise<boolean>
68
+ tokenSymbol: string
69
+ tokenDecimals: number
70
+ explorerUrl?: string | undefined
71
+ /** Keys already shown in the challenge display (to avoid duplicating in receipts) */
72
+ shownKeys: Set<string>
73
+ }
@@ -0,0 +1,143 @@
1
+ import { Errors, z } from 'incur'
2
+ import { stripe as stripeMethods } from '../../stripe/client/index.js'
3
+ import { pc } from '../utils.js'
4
+ import { createPlugin } from './plugin.js'
5
+
6
+ export function stripe() {
7
+ return createPlugin({
8
+ method: 'stripe',
9
+
10
+ async setup({ challenge, methodOpts }) {
11
+ const challengeRequest = challenge.request as Record<string, unknown>
12
+ const currency = challengeRequest.currency as string | undefined
13
+
14
+ const stripeOpts = parseOptions(
15
+ z.object({
16
+ paymentMethod: z.string(),
17
+ }),
18
+ methodOpts,
19
+ )
20
+
21
+ const stripeSecretKey = process.env.MPPX_STRIPE_SECRET_KEY
22
+ if (!stripeSecretKey)
23
+ throw new Errors.IncurError({
24
+ code: 'MISSING_ENV',
25
+ message: 'MPPX_STRIPE_SECRET_KEY environment variable is required for Stripe payments.',
26
+ exitCode: 2,
27
+ })
28
+ if (!stripeSecretKey.startsWith('sk_test_'))
29
+ throw new Errors.IncurError({
30
+ code: 'UNSUPPORTED_MODE',
31
+ message:
32
+ 'Stripe CLI payments are currently only supported in test mode (sk_test_... keys).',
33
+ exitCode: 2,
34
+ })
35
+
36
+ return {
37
+ tokenSymbol: currency?.toUpperCase() ?? '',
38
+ tokenDecimals: (challengeRequest.decimals as number | undefined) ?? 2,
39
+ methods: [
40
+ stripeMethods.charge({
41
+ paymentMethod: stripeOpts.paymentMethod,
42
+ createToken: async ({
43
+ paymentMethod,
44
+ amount,
45
+ currency,
46
+ networkId,
47
+ expiresAt,
48
+ metadata,
49
+ }) => {
50
+ const body = new URLSearchParams({
51
+ payment_method: paymentMethod!,
52
+ 'usage_limits[currency]': currency,
53
+ 'usage_limits[max_amount]': amount,
54
+ 'usage_limits[expires_at]': expiresAt.toString(),
55
+ })
56
+ if (networkId) body.set('seller_details[network_id]', networkId)
57
+ if (metadata) {
58
+ for (const [key, value] of Object.entries(metadata)) {
59
+ body.set(`metadata[${key}]`, value)
60
+ }
61
+ }
62
+
63
+ const sptUrl =
64
+ process.env.MPPX_STRIPE_SPT_URL ??
65
+ 'https://api.stripe.com/v1/test_helpers/shared_payment/granted_tokens'
66
+ const sptHeaders = {
67
+ Authorization: `Basic ${btoa(`${stripeSecretKey}:`)}`,
68
+ 'Content-Type': 'application/x-www-form-urlencoded',
69
+ }
70
+
71
+ let response = await globalThis.fetch(sptUrl, {
72
+ method: 'POST',
73
+ headers: sptHeaders,
74
+ body,
75
+ })
76
+ if (!response.ok) {
77
+ const errorBody = (await response.json()) as { error: { message: string } }
78
+ if (
79
+ (metadata || networkId) &&
80
+ errorBody.error.message.includes('Received unknown parameter')
81
+ ) {
82
+ const fallbackBody = new URLSearchParams({
83
+ payment_method: paymentMethod!,
84
+ 'usage_limits[currency]': currency,
85
+ 'usage_limits[max_amount]': amount,
86
+ 'usage_limits[expires_at]': expiresAt.toString(),
87
+ })
88
+ response = await globalThis.fetch(sptUrl, {
89
+ method: 'POST',
90
+ headers: sptHeaders,
91
+ body: fallbackBody,
92
+ })
93
+ if (!response.ok) {
94
+ const fallbackError = (await response.json()) as {
95
+ error: { message: string }
96
+ }
97
+ throw new Errors.IncurError({
98
+ code: 'STRIPE_ERROR',
99
+ message: `Failed to create SPT: ${fallbackError.error.message}`,
100
+ exitCode: 77,
101
+ })
102
+ }
103
+ } else
104
+ throw new Errors.IncurError({
105
+ code: 'STRIPE_ERROR',
106
+ message: `Failed to create SPT: ${errorBody.error.message}`,
107
+ exitCode: 77,
108
+ })
109
+ }
110
+ const { id } = (await response.json()) as { id: string }
111
+ return id
112
+ },
113
+ }),
114
+ ],
115
+ }
116
+ },
117
+
118
+ formatReceiptField(key, value) {
119
+ if (key === 'reference' && typeof value === 'string' && value.startsWith('pi_')) {
120
+ const isTest = process.env.MPPX_STRIPE_SECRET_KEY?.startsWith('sk_test_')
121
+ const url = `https://dashboard.stripe.com${isTest ? '/test' : ''}/payments/${value}`
122
+ return pc.link(url, value, true)
123
+ }
124
+ },
125
+ })
126
+ }
127
+
128
+ // --- Helpers ---
129
+
130
+ function parseOptions<const schema extends z.ZodType>(
131
+ schema: schema,
132
+ rawOptions: unknown,
133
+ ): z.output<schema> {
134
+ const result = schema.safeParse(rawOptions ?? {})
135
+ if (result.success) return result.data
136
+ const summary = result.error.issues
137
+ .map((issue) => {
138
+ const path = issue.path.length ? issue.path.join('.') : 'options'
139
+ return `${path}: ${issue.message}`
140
+ })
141
+ .join(', ')
142
+ throw new Error(`Invalid CLI options (${summary})`)
143
+ }