Skip to content
Merged
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
3 changes: 2 additions & 1 deletion .changeset/bright-vault-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@
'@inflowpayai/inflow': patch
---

Start the local vault daemon before reporting authentication or vault status.
Start the local vault daemon for vault status and interactive authentication status. Report an unauthenticated agent
status without starting a vault that has not been initialized.
5 changes: 5 additions & 0 deletions .changeset/fresh-sdk-dependencies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@inflowpayai/inflow': patch
---

Update the AEP, ODP, and InFlow SDK dependencies.
6 changes: 6 additions & 0 deletions .changeset/tidy-vault-prompts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@inflowpayai/inflow': patch
---

Prompt interactive users to unlock the vault before authentication status, combined inspection, and payment cancellation
commands.
6 changes: 3 additions & 3 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,11 @@
"url": "https://github.com/inflowpayai/inflow-cli/issues"
},
"dependencies": {
"@aep-foundation/agent": "^0.4.0",
"@aep-foundation/agent": "^0.5.0",
"@inflowpayai/mpp": "^0.10.0",
"@inflowpayai/mpp-buyer": "^0.7.2",
"@inflowpayai/x402": "^0.9.0",
"@inflowpayai/x402-buyer": "^0.9.0",
"@inflowpayai/x402": "^0.9.1",
"@inflowpayai/x402-buyer": "^0.9.1",
"@modelcontextprotocol/server": "2.0.0-alpha.4",
"@node-rs/argon2": "^2.0.2",
"@x402/core": "^2.22.0",
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,11 +176,13 @@ async function main(): Promise<void> {
const vaultOptions: LocalVaultDaemonClientOptions = { buildId: cliBuildId, cliVersion };
const apiKeyFromEnv = process.env['INFLOW_API_KEY'];
const hasDirectApiKey = (apiKeyFromFlag?.length ?? 0) > 0 || (apiKeyFromEnv?.length ?? 0) > 0;
let hasInitializedVault = true;
if (shouldReconcileVaultDaemon(process.argv, hasDirectApiKey)) {
const status = await readVaultStatusWithoutStarting(vaultOptions);
hasInitializedVault = status.lockState !== 'not_initialized';
if (status.daemonRunning) await ensureLocalVaultDaemon(vaultOptions);
}
if (shouldStartVaultDaemon(process.argv, hasDirectApiKey)) {
if (shouldStartVaultDaemon(process.argv, { hasDirectApiKey, hasInitializedVault, isAgent })) {
await ensureLocalVaultDaemon(vaultOptions);
}
if (shouldUnlockVault(process.argv, { hasDirectApiKey, isAgent })) {
Expand Down
18 changes: 16 additions & 2 deletions packages/cli/src/commands/aep/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1376,8 +1376,22 @@ async function runRevoke(c: Context, inflow: Inflow, authStorage: AuthStorage):
? { grantType: options.grantType as string }
: { credentialId: options.credentialId };
if ('allGrantTypes' in selector) await revokeService({ ...base, allGrantTypes: true });
else if ('credentialId' in selector) await revokeService({ ...base, credentialId: selector.credentialId });
else await revokeService({ ...base, grantType: selector.grantType });
else if ('credentialId' in selector) {
const credential = await aepStorage
.credentials()
.findCredential(inspect.document.service.did, selector.credentialId);
if (credential === undefined) {
throw new CliInputError(
'AEP_CREDENTIAL_NOT_FOUND',
`No stored AEP credential exists with identifier ${selector.credentialId}.`,
);
}
await revokeService({
...base,
credentialId: selector.credentialId,
grantType: credential.grantType,
});
} else await revokeService({ ...base, grantType: selector.grantType });
aepStorage.deleteCredentials(inspect.document.service.did, selector);
const frame = sanitizeDeep({
revoked: true,
Expand Down
35 changes: 26 additions & 9 deletions packages/cli/src/startup-vault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,21 +45,33 @@ export function normalizeFormatAssignments(argv: string[]): void {
}
}

export function shouldStartVaultDaemon(argv: readonly string[], hasDirectApiKey = false): boolean {
export function shouldStartVaultDaemon(
argv: readonly string[],
options: { hasDirectApiKey?: boolean; hasInitializedVault?: boolean; isAgent?: boolean } = {},
): boolean {
if (shouldBypassVault(argv)) return false;
const [group, subcommand] = commandPath(argv);
if (group === 'auth') {
return isOneOf(subcommand, 'login', 'logout') || (!hasDirectApiKey && subcommand === 'status');
return (
isOneOf(subcommand, 'login', 'logout') ||
(options.hasDirectApiKey !== true &&
subcommand === 'status' &&
(options.isAgent !== true || options.hasInitializedVault !== false))
);
}
if (group === 'vault') return subcommand === 'status';
if (group === 'aep') return isOneOf(subcommand, 'enroll', 'fetch', 'grant', 'revoke', 'status');
if (group === 'inspect') return shouldConfigureOdpServiceTransport(argv);
if (group === 'odp') return shouldConfigureOdpServiceTransport(argv);
if (group === 'mpp') {
return (
requiresMppLocalState(argv, subcommand) || (!hasDirectApiKey && isOneOf(subcommand, 'pay', 'status', 'supported'))
requiresMppLocalState(argv, subcommand) ||
(options.hasDirectApiKey !== true && isOneOf(subcommand, 'cancel', 'pay', 'status', 'supported'))
);
}
if (group === 'x402') return !hasDirectApiKey && isOneOf(subcommand, 'fetch', 'pay', 'status', 'supported');
if (group === 'x402') {
return options.hasDirectApiKey !== true && isOneOf(subcommand, 'cancel', 'fetch', 'pay', 'status', 'supported');
}
if (group === 'subscriptions') return isOneOf(subcommand, 'cancel', 'fetch');
return false;
}
Expand All @@ -71,13 +83,15 @@ export function shouldReconcileVaultDaemon(argv: readonly string[], hasDirectApi
return isOneOf(subcommand, 'login', 'logout') || (!hasDirectApiKey && subcommand === 'status');
}
if (group === 'aep') return isOneOf(subcommand, 'enroll', 'fetch', 'grant', 'revoke', 'status');
if (group === 'inspect') return shouldConfigureOdpServiceTransport(argv);
if (group === 'odp') return shouldConfigureOdpServiceTransport(argv);
if (group === 'mpp') {
return (
requiresMppLocalState(argv, subcommand) || (!hasDirectApiKey && isOneOf(subcommand, 'pay', 'status', 'supported'))
requiresMppLocalState(argv, subcommand) ||
(!hasDirectApiKey && isOneOf(subcommand, 'cancel', 'pay', 'status', 'supported'))
);
}
if (group === 'x402') return !hasDirectApiKey && isOneOf(subcommand, 'fetch', 'pay', 'status', 'supported');
if (group === 'x402') return !hasDirectApiKey && isOneOf(subcommand, 'cancel', 'fetch', 'pay', 'status', 'supported');
if (group === 'subscriptions' && isOneOf(subcommand, 'cancel', 'fetch')) return true;
if (hasDirectApiKey) return false;
if (group === 'balances' || group === 'deposit-addresses') return subcommand === 'list';
Expand All @@ -91,17 +105,20 @@ export function shouldUnlockVault(
): boolean {
if (options.isAgent === true || shouldBypassVault(argv)) return false;
const [group, subcommand] = commandPath(argv);
if (group === 'auth') return subcommand === 'login';
if (group === 'auth') {
return subcommand === 'login' || (options.hasDirectApiKey !== true && subcommand === 'status');
}
if (group === 'aep') return isOneOf(subcommand, 'enroll', 'fetch', 'grant', 'revoke', 'status');
if (group === 'inspect') return shouldConfigureOdpServiceTransport(argv);
if (group === 'odp') return shouldConfigureOdpServiceTransport(argv);
if (group === 'mpp') {
return (
requiresMppLocalState(argv, subcommand) ||
(options.hasDirectApiKey !== true && isOneOf(subcommand, 'pay', 'status', 'supported'))
(options.hasDirectApiKey !== true && isOneOf(subcommand, 'cancel', 'pay', 'status', 'supported'))
);
}
if (group === 'x402') {
return options.hasDirectApiKey !== true && isOneOf(subcommand, 'fetch', 'pay', 'status', 'supported');
return options.hasDirectApiKey !== true && isOneOf(subcommand, 'cancel', 'fetch', 'pay', 'status', 'supported');
}
if (group === 'subscriptions' && isOneOf(subcommand, 'cancel', 'fetch')) return true;
if (options.hasDirectApiKey === true) return false;
Expand Down
15 changes: 15 additions & 0 deletions packages/cli/src/utils/api-error.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { SecureStorageError } from '@inflowpayai/inflow-core';
import { MISSING_SESSION_ERROR } from './assert-session.js';

interface CliError {
Expand All @@ -18,6 +19,20 @@ function apiErrorLike(error: unknown): ApiErrorLike | undefined {
}

export function authenticatedApiError(error: unknown): CliError | undefined {
if (error instanceof SecureStorageError) {
if (error.secureStorageCode === 'vault_locked') {
return {
code: 'VAULT_LOCKED',
message: 'The InFlow vault is locked. A human must run `inflow vault unlock` first.',
};
}
if (error.secureStorageCode === 'vault_not_initialized') {
return {
code: 'VAULT_NOT_INITIALIZED',
message: 'The InFlow vault is not initialized. A human must run `inflow vault unlock` first.',
};
}
}
const apiError = apiErrorLike(error);
if (apiError === undefined) return;
if (apiError.code === 'VERSION_UNSUPPORTED' && typeof apiError.message === 'string') {
Expand Down
24 changes: 24 additions & 0 deletions packages/cli/test/unit/commands/aep/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1205,10 +1205,34 @@ describe('aep commands', () => {
userId: 'user-1',
});
await persisted.identities().saveIdentity(identity);
if ('credentialId' in options) {
await persisted.credentials().saveCredential({
credential: { credential_id: options.credentialId },
credentialId: options.credentialId,
expiresAt: '2999-01-01T00:00:00.000Z',
grantType: 'oauth-bearer',
issuedAt: '2026-01-01T00:00:00.000Z',
serviceDid: identity.serviceDid,
});
}

await expect(__testing.runRevoke(context(options), inflow(), storage)).resolves.toEqual(expected);
});

it('rejects per-credential Revoke when the credential is not stored locally', async () => {
const storage = new MemoryStorage();
storage.setApiKey('key');
const persisted = new AepStorage(storage, {
platformOrigin: 'https://platform.example',
userId: 'user-1',
});
await persisted.identities().saveIdentity(identity);

await expect(
__testing.runRevoke(context({ credentialId: 'credential-missing' }), inflow(), storage),
).rejects.toThrow('AEP_CREDENTIAL_NOT_FOUND');
});

it('checks Status and skips approval when enrolling an existing identity', async () => {
const approvalFetch = vi.fn(() =>
Promise.resolve(new Response(JSON.stringify({ status: 'APPROVED' }), { status: 200 })),
Expand Down
12 changes: 11 additions & 1 deletion packages/cli/test/unit/commands/mpp/index-runners.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { AuthStorage, ICliCapabilitiesResource } from '@inflowpayai/inflow-core';
import { Inflow, InflowApiError, MemoryStorage } from '@inflowpayai/inflow-core';
import { Inflow, InflowApiError, MemoryStorage, SecureStorageError } from '@inflowpayai/inflow-core';
import { encode, type MppChallenge, type MppClient, renderChallengeHeader } from '@inflowpayai/mpp';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { __testing, createMppCli } from '../../../../src/commands/mpp/index.js';
Expand Down Expand Up @@ -151,6 +151,16 @@ describe('mpp agent runners', () => {
expect(out).toMatchObject({ code: 'NOT_AUTHENTICATED' });
});

it('runCancelCommand reports a locked vault in agent mode', async () => {
const cancelApproval = vi.fn(() =>
Promise.reject(new SecureStorageError('vault_locked', 'The InFlow vault is locked.')),
);
const { inflow, storage } = authed(makeClient(), cancelApproval);
const ctx = agentCtxReturningError({ approvalId: 'ap-1' }, {});
const out = await runCancelCommand(ctx, inflow, storage);
expect(out).toMatchObject({ code: 'VAULT_LOCKED' });
});

it('runCancelCommand rethrows non-authentication failures', async () => {
const failure = new Error('cancel unavailable');
const cancelApproval = vi.fn(() => Promise.reject(failure));
Expand Down
14 changes: 13 additions & 1 deletion packages/cli/test/unit/commands/x402/index-runners.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { AuthStorage, ICliCapabilitiesResource } from '@inflowpayai/inflow-core';
import { Inflow, InflowApiError, MemoryStorage } from '@inflowpayai/inflow-core';
import { Inflow, InflowApiError, MemoryStorage, SecureStorageError } from '@inflowpayai/inflow-core';
import {
X402AdapterRoutingError,
X402ApprovalFailedError,
Expand Down Expand Up @@ -1049,6 +1049,18 @@ describe('runCancelCommand', () => {
expect(result).toMatchObject({ code: 'NOT_AUTHENTICATED' });
});

it('reports a locked vault in agent mode', async () => {
const client = makeClient({
cancelApproval: vi.fn(() =>
Promise.reject(new SecureStorageError('vault_locked', 'The InFlow vault is locked.')),
),
});
const ctx = agentContextReturningError({ approvalId: 'appr_1' }, {});
const { inflow, storage } = authedResources(client);
const result = await runCancelCommand(ctx, inflow, storage);
expect(result).toMatchObject({ code: 'VAULT_LOCKED' });
});

it('rethrows non-authentication failures', async () => {
const failure = new Error('cancel unavailable');
const client = makeClient({
Expand Down
Loading